Compare commits

...
Author SHA1 Message Date
Anthony Stirling ccaae128b7 tauri removal for now 2025-11-21 13:08:52 +00:00
Anthony Stirling 63bcb73637 tauri 2025-11-19 15:15:35 +00:00
Anthony Stirling 531a7fdbcc again 2025-11-19 12:35:29 +00:00
Anthony Stirling 920f2898e1 Merge remote-tracking branch 'origin/V2' into
codex/update-login-logic-for-error-handling
2025-11-19 12:10:52 +00:00
Anthony Stirling dba316c14f startup screen 2025-11-19 12:04:52 +00:00
Anthony StirlingandGitHub 6177ccd333 update notif (#4937)
# Description of Changes
<img width="1521" height="1041" alt="image"
src="https://github.com/user-attachments/assets/2644bf70-0a9b-4c91-9046-02f555314608"
/>

<img width="1162" height="1598" alt="image"
src="https://github.com/user-attachments/assets/36d693f7-6fdd-4f2b-9db1-39ac336d9055"
/>

<img width="1220" height="1625" alt="image"
src="https://github.com/user-attachments/assets/4d4c19ea-0020-45fb-b15a-9f6ad377856c"
/>

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-19 11:54:33 +00:00
Anthony Stirling daf044a7e4 langs 2025-11-17 21:36:22 +00:00
Anthony Stirling 81512b7805 Clarify backend unavailable login header 2025-11-16 10:43:48 +00:00
13 changed files with 1267 additions and 29 deletions
@@ -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)
}
}
@@ -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) {
+49 -2
View File
@@ -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;
}
+186
View File
@@ -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();
+1 -1
View File
@@ -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)
+2
View File
@@ -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>
);
}
+61 -3
View File
@@ -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 />;
}
+166 -15
View File
@@ -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>
)}