Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccaae128b7 | ||
|
|
63bcb73637 | ||
|
|
531a7fdbcc | ||
|
|
920f2898e1 | ||
|
|
dba316c14f | ||
|
|
6177ccd333 | ||
|
|
daf044a7e4 | ||
|
|
81512b7805 |
@@ -63,13 +63,51 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedOriginPatterns(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else {
|
||||
// Default to allowing all origins when nothing is configured
|
||||
logger.info(
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
|
||||
// default)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -154,6 +154,25 @@ public class ConfigController {
|
||||
// EE features not available, continue without them
|
||||
}
|
||||
|
||||
// Add version and machine info for update checking
|
||||
try {
|
||||
if (applicationContext.containsBean("appVersion")) {
|
||||
configData.put(
|
||||
"appVersion", applicationContext.getBean("appVersion", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("machineType")) {
|
||||
configData.put(
|
||||
"machineType", applicationContext.getBean("machineType", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("activeSecurity")) {
|
||||
configData.put(
|
||||
"activeSecurity",
|
||||
applicationContext.getBean("activeSecurity", Boolean.class));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -362,7 +362,15 @@
|
||||
"defaultPdfEditorInactive": "Another application is set as default",
|
||||
"defaultPdfEditorChecking": "Checking...",
|
||||
"defaultPdfEditorSet": "Already Default",
|
||||
"setAsDefault": "Set as Default"
|
||||
"setAsDefault": "Set as Default",
|
||||
"updates": {
|
||||
"title": "Software Updates",
|
||||
"description": "Check for updates and view version information",
|
||||
"currentVersion": "Current Version",
|
||||
"latestVersion": "Latest Version",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"viewDetails": "View Details"
|
||||
}
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
@@ -383,6 +391,37 @@
|
||||
"searchPlaceholder": "Search tools..."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"modalTitle": "Update Available",
|
||||
"current": "Current Version",
|
||||
"latest": "Latest Version",
|
||||
"latestStable": "Latest Stable",
|
||||
"priorityLabel": "Priority",
|
||||
"recommendedAction": "Recommended Action",
|
||||
"breakingChangesDetected": "Breaking Changes Detected",
|
||||
"breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.",
|
||||
"migrationGuides": "Migration Guides",
|
||||
"viewGuide": "View Guide",
|
||||
"loadingDetailedInfo": "Loading detailed information...",
|
||||
"close": "Close",
|
||||
"viewAllReleases": "View All Releases",
|
||||
"downloadLatest": "Download Latest",
|
||||
"availableUpdates": "Available Updates",
|
||||
"unableToLoadDetails": "Unable to load detailed information.",
|
||||
"version": "Version",
|
||||
"urgentUpdateAvailable": "Urgent Update",
|
||||
"updateAvailable": "Update Available",
|
||||
"releaseNotes": "Release Notes",
|
||||
"priority": {
|
||||
"urgent": "Urgent",
|
||||
"normal": "Normal",
|
||||
"minor": "Minor",
|
||||
"low": "Low"
|
||||
},
|
||||
"breakingChanges": "Breaking Changes",
|
||||
"breakingChangesDefault": "This version contains breaking changes.",
|
||||
"migrationGuide": "Migration Guide"
|
||||
},
|
||||
"changeCreds": {
|
||||
"title": "Change Credentials",
|
||||
"header": "Update Your Account Details",
|
||||
@@ -3342,7 +3381,15 @@
|
||||
"unexpectedError": "Unexpected error: {{message}}",
|
||||
"accountCreatedSuccess": "Account created successfully! You can now sign in.",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.",
|
||||
"credentialsUpdated": "Your credentials have been updated. Please sign in again."
|
||||
"credentialsUpdated": "Your credentials have been updated. Please sign in again.",
|
||||
"backendLoadingTitle": "Backend starting up",
|
||||
"backendLoadingMessage": "Backend is currently loading and/or not started. Please wait a few moments and try again."
|
||||
},
|
||||
"backendStartup": {
|
||||
"title": "Backend Starting",
|
||||
"loadingTitle": "Backend starting up",
|
||||
"loadingMessage": "The backend is currently starting up. This usually takes a few moments.",
|
||||
"takingLonger": "This is taking longer than expected. The backend should be ready soon."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
interface UpdateModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
updateSummary: UpdateSummary;
|
||||
machineInfo: MachineInfo;
|
||||
}
|
||||
|
||||
const UpdateModal: React.FC<UpdateModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
currentVersion,
|
||||
updateSummary,
|
||||
machineInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setLoading(true);
|
||||
setExpandedVersions(new Set([0]));
|
||||
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
|
||||
setFullUpdateInfo(info);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [opened, currentVersion, machineInfo]);
|
||||
|
||||
const toggleVersion = (index: number) => {
|
||||
setExpandedVersions((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'urgent':
|
||||
return 'red';
|
||||
case 'normal':
|
||||
return 'blue';
|
||||
case 'minor':
|
||||
return 'cyan';
|
||||
case 'low':
|
||||
return 'gray';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string): string => {
|
||||
const key = priority?.toLowerCase();
|
||||
return t(`update.priority.${key}`, priority || 'Normal');
|
||||
};
|
||||
|
||||
const downloadUrl = updateService.getDownloadUrl(machineInfo);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t('update.modalTitle', 'Update Available')}
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
size="xl"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: '75vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Version Summary Section */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.current', 'Current Version')}
|
||||
</Text>
|
||||
<Text fw={600} size="xl">
|
||||
{currentVersion}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4} style={{ flex: 1 }} ta="center">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.priorityLabel', 'Priority')}
|
||||
</Text>
|
||||
<Badge
|
||||
color={getPriorityColor(updateSummary.max_priority)}
|
||||
size="lg"
|
||||
variant="filled"
|
||||
style={{ alignSelf: 'center' }}
|
||||
>
|
||||
{getPriorityLabel(updateSummary.max_priority)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4} style={{ flex: 1 }} ta="right">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.latest', 'Latest Version')}
|
||||
</Text>
|
||||
<Text fw={600} size="xl" c="blue">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{updateSummary.latest_stable_version && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-green-0)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-green-2)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" justify="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('update.latestStable', 'Latest Stable')}:
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="green">
|
||||
{updateSummary.latest_stable_version}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Recommended action */}
|
||||
{updateSummary.recommended_action && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-blue-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} mb={4} tt="uppercase">
|
||||
{t('update.recommendedAction', 'Recommended Action')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{updateSummary.recommended_action}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Breaking changes warning */}
|
||||
{updateSummary.any_breaking && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-orange-light)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-orange-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} mb={4} tt="uppercase">
|
||||
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'update.breakingChangesMessage',
|
||||
'Some versions contain breaking changes. Please review the migration guides below before updating.'
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Migration guides */}
|
||||
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
|
||||
{t('update.migrationGuides', 'Migration Guides')}
|
||||
</Text>
|
||||
{updateSummary.migration_guides.map((guide, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('update.version', 'Version')} {guide.version}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{guide.notes}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component="a"
|
||||
href={guide.url}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="xs"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.viewGuide', 'View Guide')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Version details */}
|
||||
<Divider />
|
||||
{loading ? (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
|
||||
{t('update.availableUpdates', 'Available Updates')}
|
||||
</Text>
|
||||
<Badge variant="light" color="gray">
|
||||
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{fullUpdateInfo.new_versions.map((version, index) => {
|
||||
const isExpanded = expandedVersions.has(index);
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="md"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onClick={() => toggleVersion(index)}
|
||||
>
|
||||
<Group gap="md" style={{ flex: 1 }}>
|
||||
<Box>
|
||||
<Text fw={600} size="sm" c="dimmed" mb={2}>
|
||||
{t('update.version', 'Version')}
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{version.version}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge color={getPriorityColor(version.priority)} size="md">
|
||||
{getPriorityLabel(version.priority)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v${version.version}`}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.releaseNotes', 'Release Notes')}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
) : (
|
||||
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<Stack gap="md" mt="md">
|
||||
<Box>
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
{version.announcement.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
|
||||
{version.announcement.message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{version.compatibility.breaking_changes && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-orange-light)',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--mantine-color-orange-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
|
||||
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
|
||||
<Text size="xs" fw={600} tt="uppercase">
|
||||
{t('update.breakingChanges', 'Breaking Changes')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" mb="xs">
|
||||
{version.compatibility.breaking_description ||
|
||||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
|
||||
</Text>
|
||||
{version.compatibility.migration_guide_url && (
|
||||
<Button
|
||||
component="a"
|
||||
href={version.compatibility.migration_guide_url}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.migrationGuide', 'Migration Guide')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('update.close', 'Close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
component="a"
|
||||
href="https://github.com/Stirling-Tools/Stirling-PDF/releases"
|
||||
target="_blank"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('update.viewAllReleases', 'View All Releases')}
|
||||
</Button>
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
color="green"
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('update.downloadLatest', 'Download Latest')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateModal;
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { updateService, UpdateSummary } from '@app/services/updateService';
|
||||
import UpdateModal from '@app/components/shared/UpdateModal';
|
||||
|
||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
|
||||
@@ -22,12 +24,44 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
// Check localStorage on mount
|
||||
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
|
||||
});
|
||||
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
|
||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
|
||||
// Sync local state with preference changes
|
||||
useEffect(() => {
|
||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||
}, [preferences.autoUnzipFileLimit]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (config?.appVersion && config?.machineType) {
|
||||
checkForUpdate();
|
||||
}
|
||||
}, [config?.appVersion, config?.machineType]);
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
if (!config?.appVersion || !config?.machineType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingUpdate(true);
|
||||
const machineInfo = {
|
||||
machineType: config.machineType,
|
||||
activeSecurity: config.activeSecurity ?? false,
|
||||
licenseType: config.license ?? 'NORMAL',
|
||||
};
|
||||
|
||||
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
|
||||
if (summary) {
|
||||
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
|
||||
if (isNewerVersion) {
|
||||
setUpdateSummary(summary);
|
||||
}
|
||||
}
|
||||
setCheckingUpdate(false);
|
||||
};
|
||||
|
||||
// Check if login is disabled
|
||||
const loginDisabled = !config?.enableLogin;
|
||||
|
||||
@@ -170,6 +204,108 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t('settings.general.updates.title', 'Software Updates')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.updates.description', 'Check for updates and view version information')}
|
||||
</Text>
|
||||
</div>
|
||||
{updateSummary && (
|
||||
<Badge
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
variant="filled"
|
||||
>
|
||||
{updateSummary.max_priority === 'urgent'
|
||||
? t('update.urgentUpdateAvailable', 'Urgent Update')
|
||||
: t('update.updateAvailable', 'Update Available')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.updates.currentVersion', 'Current Version')}:{' '}
|
||||
<Text component="span" fw={500}>
|
||||
{config.appVersion}
|
||||
</Text>
|
||||
</Text>
|
||||
{updateSummary && (
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t('settings.general.updates.latestVersion', 'Latest Version')}:{' '}
|
||||
<Text component="span" fw={500} c="blue">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={checkForUpdate}
|
||||
loading={checkingUpdate}
|
||||
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.checkForUpdates', 'Check for Updates')}
|
||||
</Button>
|
||||
{updateSummary && (
|
||||
<Button
|
||||
size="sm"
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
onClick={() => setUpdateModalOpened(true)}
|
||||
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{updateSummary?.any_breaking && (
|
||||
<Alert
|
||||
color="orange"
|
||||
title={t('update.breakingChangesDetected', 'Breaking Changes Detected')}
|
||||
styles={{
|
||||
title: { fontWeight: 600 }
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'update.breakingChangesMessage',
|
||||
'Some versions contain breaking changes. Please review the migration guides before updating.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Update Modal */}
|
||||
{updateSummary && config?.appVersion && config?.machineType && (
|
||||
<UpdateModal
|
||||
opened={updateModalOpened}
|
||||
onClose={() => setUpdateModalOpened(false)}
|
||||
currentVersion={config.appVersion}
|
||||
updateSummary={updateSummary}
|
||||
machineInfo={{
|
||||
machineType: config.machineType,
|
||||
activeSecurity: config.activeSecurity ?? false,
|
||||
licenseType: config.license ?? 'NORMAL',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface AppConfig {
|
||||
license?: string;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
export interface UpdateSummary {
|
||||
latest_version: string;
|
||||
latest_stable_version?: string;
|
||||
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
recommended_action?: string;
|
||||
any_breaking: boolean;
|
||||
migration_guides?: Array<{
|
||||
version: string;
|
||||
notes: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface VersionUpdate {
|
||||
version: string;
|
||||
priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
announcement: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
compatibility: {
|
||||
breaking_changes: boolean;
|
||||
breaking_description?: string;
|
||||
migration_guide_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FullUpdateInfo {
|
||||
latest_version: string;
|
||||
latest_stable_version?: string;
|
||||
new_versions: VersionUpdate[];
|
||||
}
|
||||
|
||||
export interface MachineInfo {
|
||||
machineType: string;
|
||||
activeSecurity: boolean;
|
||||
licenseType: string;
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates';
|
||||
|
||||
/**
|
||||
* Compare two version strings
|
||||
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
compareVersions(version1: string, version2: string): number {
|
||||
const v1 = version1.split('.');
|
||||
const v2 = version2.split('.');
|
||||
|
||||
for (let i = 0; i < v1.length || i < v2.length; i++) {
|
||||
const n1 = parseInt(v1[i]) || 0;
|
||||
const n2 = parseInt(v2[i]) || 0;
|
||||
|
||||
if (n1 > n2) {
|
||||
return 1;
|
||||
} else if (n1 < n2) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL based on machine type and security settings
|
||||
*/
|
||||
getDownloadUrl(machineInfo: MachineInfo): string | null {
|
||||
// Only show download for non-Docker installations
|
||||
if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = 'https://files.stirlingpdf.com/';
|
||||
|
||||
// Determine file based on machine type and security
|
||||
if (machineInfo.machineType === 'Server-jar') {
|
||||
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
|
||||
}
|
||||
|
||||
// Client installations
|
||||
if (machineInfo.machineType.startsWith('Client-')) {
|
||||
const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix
|
||||
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
|
||||
|
||||
if (os === 'unix') {
|
||||
return baseUrl + os + type + '.jar';
|
||||
} else if (os === 'win') {
|
||||
return baseUrl + os + '-installer.exe';
|
||||
} else if (os === 'mac') {
|
||||
return baseUrl + os + '-installer.dmg';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch update summary from API
|
||||
*/
|
||||
async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise<UpdateSummary | null> {
|
||||
// Map Java License enum to API types
|
||||
let type = 'normal';
|
||||
if (machineInfo.licenseType === 'PRO') {
|
||||
type = 'pro';
|
||||
} else if (machineInfo.licenseType === 'ENTERPRISE') {
|
||||
type = 'enterprise';
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`;
|
||||
console.log('Fetching update summary from:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data as UpdateSummary;
|
||||
} else {
|
||||
console.error('Failed to fetch update summary from Supabase:', response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch update summary from Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full update information with detailed version info
|
||||
*/
|
||||
async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise<FullUpdateInfo | null> {
|
||||
// Map Java License enum to API types
|
||||
let type = 'normal';
|
||||
if (machineInfo.licenseType === 'PRO') {
|
||||
type = 'pro';
|
||||
} else if (machineInfo.licenseType === 'ENTERPRISE') {
|
||||
type = 'enterprise';
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`;
|
||||
console.log('Fetching full update info from:', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log('Full update response status:', response.status);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data as FullUpdateInfo;
|
||||
} else {
|
||||
console.error('Failed to fetch full update info from Supabase:', response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch full update info from Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version from GitHub build.gradle as fallback
|
||||
*/
|
||||
async getCurrentVersionFromGitHub(): Promise<string> {
|
||||
const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
|
||||
const match = versionRegex.exec(text);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
throw new Error('Version number not found');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest version from build.gradle:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateService = new UpdateService();
|
||||
@@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100;
|
||||
export const Z_INDEX_SELECTION_BOX = 1000;
|
||||
export const Z_INDEX_DROP_INDICATOR = 1001;
|
||||
export const Z_INDEX_DRAG_BADGE = 1001;
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation)
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
|
||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
||||
|
||||
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
|
||||
|
||||
@@ -8,6 +8,7 @@ import Login from "@app/routes/Login";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import BackendStartup from "@app/routes/BackendStartup";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
|
||||
// Import global styles
|
||||
@@ -30,6 +31,7 @@ export default function App() {
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
<Route path="/backend-startup" element={<BackendStartup />} />
|
||||
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
export default function BackendStartup() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [pollAttempt, setPollAttempt] = useState(0);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('backendStartup.title', 'Backend Starting')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('backendStartup.title', 'Backend Starting')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
});
|
||||
|
||||
// Poll backend to check if it's ready
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const checkBackendStatus = async () => {
|
||||
try {
|
||||
// Use a universal endpoint that works in both proprietary and non-proprietary builds
|
||||
// Try /api/v1/info/status first (works in both builds if metrics enabled)
|
||||
const response = await fetch(`${BASE_PATH}/api/v1/info/status`, {
|
||||
method: 'GET',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Backend is ready if we get valid status back
|
||||
if (data && data.status === 'UP') {
|
||||
console.log('[BackendStartup] Backend is ready, redirecting');
|
||||
const redirectTo = sessionStorage.getItem('backendStartupRedirect') || '/';
|
||||
sessionStorage.removeItem('backendStartupRedirect');
|
||||
|
||||
// Small delay to ensure backend is fully ready
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
navigate(redirectTo, { replace: true });
|
||||
}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If status endpoint fails with 403 (metrics disabled), try proprietary endpoint
|
||||
if (response.status === 403) {
|
||||
console.debug('[BackendStartup] Metrics disabled, trying proprietary endpoint');
|
||||
const proprietaryResponse = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`, {
|
||||
method: 'GET',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (proprietaryResponse.ok) {
|
||||
const data = await proprietaryResponse.json();
|
||||
if (data && data.enableLogin !== null) {
|
||||
console.log('[BackendStartup] Backend is ready (proprietary check), redirecting');
|
||||
const redirectTo = sessionStorage.getItem('backendStartupRedirect') || '/';
|
||||
sessionStorage.removeItem('backendStartupRedirect');
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
navigate(redirectTo, { replace: true });
|
||||
}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backend not ready yet, continue polling
|
||||
if (response.status === 404 || response.status === 503) {
|
||||
setPollAttempt(prev => prev + 1);
|
||||
setStatusMessage(`Backend not ready (${response.status})`);
|
||||
} else {
|
||||
setPollAttempt(prev => prev + 1);
|
||||
setStatusMessage(response.statusText || null);
|
||||
}
|
||||
|
||||
// Schedule next poll (exponential backoff, max 5 seconds)
|
||||
const delay = Math.min(1000 * Math.pow(1.5, pollAttempt), 5000);
|
||||
timeoutId = setTimeout(checkBackendStatus, delay);
|
||||
} catch (err) {
|
||||
// Network error or backend not responding
|
||||
if (!isMounted) return;
|
||||
|
||||
console.debug('[BackendStartup] Poll attempt failed:', err);
|
||||
setPollAttempt(prev => prev + 1);
|
||||
setStatusMessage(err instanceof Error ? err.message : null);
|
||||
|
||||
// Continue polling with backoff
|
||||
const delay = Math.min(1000 * Math.pow(1.5, pollAttempt), 5000);
|
||||
timeoutId = setTimeout(checkBackendStatus, delay);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after a short delay to avoid race conditions
|
||||
timeoutId = setTimeout(checkBackendStatus, 500);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [navigate, pollAttempt, BASE_PATH]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
{/* Header without title prop for custom styling */}
|
||||
<div className="login-header">
|
||||
<div className="login-header-logos">
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backend loading message */}
|
||||
<div
|
||||
className="auth-section"
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
marginTop: '1rem',
|
||||
borderRadius: '0.75rem',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.08)',
|
||||
border: '1px solid rgba(37, 99, 235, 0.2)',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
|
||||
{t('backendStartup.loadingTitle', 'Backend starting up')}
|
||||
</p>
|
||||
<p style={{ margin: 0, textAlign: 'center', color: 'rgba(15, 23, 42, 0.8)' }}>
|
||||
{t('backendStartup.loadingMessage', 'The backend is currently starting up. This usually takes a few moments.')}
|
||||
</p>
|
||||
|
||||
{/* Loading indicator */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '1rem' }}>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '3px solid rgba(37, 99, 235, 0.2)',
|
||||
borderTop: '3px solid rgba(37, 99, 235, 0.8)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{statusMessage && (
|
||||
<p style={{ marginTop: '0.75rem', fontSize: '0.875rem', textAlign: 'center', color: 'rgba(15, 23, 42, 0.6)' }}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pollAttempt > 10 && (
|
||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem', textAlign: 'center', color: 'rgba(15, 23, 42, 0.7)' }}>
|
||||
{t('backendStartup.takingLonger', 'This is taking longer than expected. The backend should be ready soon.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import HomePage from '@app/pages/HomePage'
|
||||
// Login component is used via routing, not directly imported
|
||||
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
|
||||
import { accountService } from '@app/services/accountService'
|
||||
import { BASE_PATH } from '@app/constants/app'
|
||||
|
||||
/**
|
||||
* Landing component - Smart router based on authentication status
|
||||
@@ -16,11 +17,12 @@ import { accountService } from '@app/services/accountService'
|
||||
*/
|
||||
export default function Landing() {
|
||||
const { session, loading: authLoading, refreshSession } = useAuth();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const { config, loading: configLoading, error: configError } = useAppConfig();
|
||||
const location = useLocation();
|
||||
const [isFirstLogin, setIsFirstLogin] = useState(false);
|
||||
const [checkingFirstLogin, setCheckingFirstLogin] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [backendCheckFailed, setBackendCheckFailed] = useState(false);
|
||||
|
||||
const loading = authLoading || configLoading;
|
||||
|
||||
@@ -54,13 +56,64 @@ export default function Landing() {
|
||||
// The auth system will automatically redirect to login when session is null
|
||||
}
|
||||
|
||||
// If AppConfig failed to load, check if backend is down
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Only check if config has an error and we haven't already redirected
|
||||
if (configError && !backendCheckFailed && !configLoading) {
|
||||
console.debug('[Landing] Config error detected, checking backend status');
|
||||
|
||||
const checkBackend = async () => {
|
||||
try {
|
||||
const response = await fetch(`${BASE_PATH}/api/v1/info/status`, {
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
// If backend responds, it's up - continue normally
|
||||
if (response.ok) {
|
||||
console.debug('[Landing] Backend is up despite config error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend returned error - redirect to startup page
|
||||
console.debug('[Landing] Backend not ready, redirecting to startup page');
|
||||
setBackendCheckFailed(true);
|
||||
sessionStorage.setItem('backendStartupRedirect', location.pathname + location.search);
|
||||
} catch (err) {
|
||||
// Network error - backend is down
|
||||
if (!isMounted) return;
|
||||
|
||||
console.debug('[Landing] Backend unavailable (network error), redirecting to startup page');
|
||||
setBackendCheckFailed(true);
|
||||
sessionStorage.setItem('backendStartupRedirect', location.pathname + location.search);
|
||||
}
|
||||
};
|
||||
|
||||
checkBackend();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [configError, configLoading, location, backendCheckFailed]);
|
||||
|
||||
console.log('[Landing] State:', {
|
||||
pathname: location.pathname,
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
loginEnabled: config?.enableLogin,
|
||||
configError,
|
||||
backendCheckFailed,
|
||||
});
|
||||
|
||||
// Redirect to backend startup if backend check failed
|
||||
if (backendCheckFailed) {
|
||||
return <Navigate to="/backend-startup" replace />;
|
||||
}
|
||||
|
||||
// Show loading while checking auth and config
|
||||
if (loading || checkingFirstLogin) {
|
||||
return (
|
||||
@@ -76,8 +129,13 @@ export default function Landing() {
|
||||
}
|
||||
|
||||
// If login is disabled, show app directly (anonymous mode)
|
||||
if (config?.enableLogin === false) {
|
||||
console.debug('[Landing] Login disabled - showing app in anonymous mode');
|
||||
// Check both the config AND sessionStorage flag (in case config is wrong)
|
||||
const loginActuallyDisabled = sessionStorage.getItem('loginActuallyDisabled') === 'true';
|
||||
if (config?.enableLogin === false || loginActuallyDisabled) {
|
||||
console.debug('[Landing] Login disabled - showing app in anonymous mode', {
|
||||
configSays: config?.enableLogin,
|
||||
actuallyDisabled: loginActuallyDisabled
|
||||
});
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
|
||||
// Import login components
|
||||
@@ -19,6 +20,7 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { session, loading } = useAuth();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -29,37 +31,169 @@ export default function Login() {
|
||||
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||
const [backendCheckFailed, setBackendCheckFailed] = useState(false);
|
||||
const [hasRedirected, setHasRedirected] = useState(false);
|
||||
|
||||
// Check AppConfig first - if login is disabled, redirect to home immediately
|
||||
useEffect(() => {
|
||||
if (!configLoading && config?.enableLogin === false && !hasRedirected) {
|
||||
console.debug('[Login] Config loaded, login disabled - redirecting to home');
|
||||
setHasRedirected(true);
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [config, configLoading, navigate, hasRedirected]);
|
||||
|
||||
// Fetch enabled SSO providers and login config from backend
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Don't fetch if config is still loading or if we know login is disabled
|
||||
if (configLoading) {
|
||||
console.debug('[Login] Waiting for config to load before fetching providers');
|
||||
return;
|
||||
}
|
||||
|
||||
if (config?.enableLogin === false) {
|
||||
console.debug('[Login] Login disabled per config, skipping provider fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`, {
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
// Check if login is disabled - if so, redirect to home
|
||||
if (data.enableLogin === false) {
|
||||
console.debug('[Login] Login disabled, redirecting to home');
|
||||
navigate('/');
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
// 404 likely means security is disabled (non-proprietary backend)
|
||||
// Check the general status endpoint to see if backend is up
|
||||
if (response.status === 404) {
|
||||
console.debug('[Login] Proprietary endpoint not found - checking if security is disabled (this is expected behavior when security is disabled)');
|
||||
|
||||
try {
|
||||
const statusResponse = await fetch(`${BASE_PATH}/api/v1/info/status`, {
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (statusResponse.ok) {
|
||||
// Backend is up, but security is disabled
|
||||
// This means the config is wrong - login is actually disabled
|
||||
console.debug('[Login] Backend is up but security disabled - redirecting to home');
|
||||
|
||||
// Set a flag in sessionStorage so Landing knows login is actually disabled
|
||||
sessionStorage.setItem('loginActuallyDisabled', 'true');
|
||||
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
} catch (statusErr) {
|
||||
console.debug('[Login] Status check failed, backend may be starting up');
|
||||
}
|
||||
|
||||
// Backend is not responding properly - redirect to startup page
|
||||
console.debug('[Login] Backend starting up - redirecting to backend startup page');
|
||||
setBackendCheckFailed(true);
|
||||
sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search);
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
navigate('/backend-startup', { replace: true });
|
||||
}
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
setEnableLogin(data.enableLogin ?? true);
|
||||
// 503 means backend is starting up
|
||||
if (response.status === 503) {
|
||||
console.warn('[Login] Backend unavailable (503) - redirecting to backend startup');
|
||||
setBackendCheckFailed(true);
|
||||
sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search);
|
||||
|
||||
// Extract provider IDs from the providerList map
|
||||
// The keys are like "/oauth2/authorization/google" - extract the last part
|
||||
const providerIds = Object.keys(data.providerList || {})
|
||||
.map(key => key.split('/').pop())
|
||||
.filter((id): id is string => id !== undefined);
|
||||
setEnabledProviders(providerIds);
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
navigate('/backend-startup', { replace: true });
|
||||
}
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `Failed to fetch login configuration (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!data || data.enableLogin === null) {
|
||||
console.warn('[Login] Login config returned empty or null data - checking backend status');
|
||||
|
||||
// Check if backend is actually up before assuming it's starting
|
||||
try {
|
||||
const statusResponse = await fetch(`${BASE_PATH}/api/v1/info/status`, {
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (statusResponse.ok) {
|
||||
// Backend is up but returning invalid data - redirect to home as fallback
|
||||
console.debug('[Login] Backend up but invalid login data - redirecting to home');
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
} catch (statusErr) {
|
||||
console.debug('[Login] Status check failed');
|
||||
}
|
||||
|
||||
// Backend is not responding - redirect to startup page
|
||||
setBackendCheckFailed(true);
|
||||
sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search);
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
navigate('/backend-startup', { replace: true });
|
||||
}
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if login is disabled - if so, redirect to home
|
||||
if (data.enableLogin === false) {
|
||||
console.debug('[Login] Login disabled, redirecting to home');
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setEnableLogin(data.enableLogin ?? true);
|
||||
|
||||
// Extract provider IDs from the providerList map
|
||||
// The keys are like "/oauth2/authorization/google" - extract the last part
|
||||
const providerIds = Object.keys(data.providerList || {})
|
||||
.map(key => key.split('/').pop())
|
||||
.filter((id): id is string => id !== undefined);
|
||||
setEnabledProviders(providerIds);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
|
||||
console.error('[Login] Failed to fetch enabled providers:', err);
|
||||
setBackendCheckFailed(true);
|
||||
sessionStorage.setItem('backendStartupRedirect', window.location.pathname + window.location.search);
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
navigate('/backend-startup', { replace: true });
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProviders();
|
||||
}, [navigate]);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [navigate, BASE_PATH, config, configLoading]);
|
||||
|
||||
// Update hasSSOProviders and showEmailForm when enabledProviders changes
|
||||
useEffect(() => {
|
||||
@@ -124,6 +258,23 @@ export default function Login() {
|
||||
return <LoggedInState />;
|
||||
}
|
||||
|
||||
// Show loading while checking backend, config, or redirecting
|
||||
// Also show loading if we know login is disabled (we're about to redirect)
|
||||
if (loading || configLoading || backendCheckFailed || hasRedirected || (!configLoading && config?.enableLogin === false)) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div style={{ minHeight: '300px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<div className="text-gray-600">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
|
||||
<div className="login-header-logos">
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
|
||||
</div>
|
||||
<h1 className="login-title">{title}</h1>
|
||||
{title && <h1 className="login-title">{title}</h1>}
|
||||
{subtitle && (
|
||||
<p className="login-subtitle">{subtitle}</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user