From 00fb40fb7450825e67a20990e117f8565f7e98db Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:33:53 +0000 Subject: [PATCH 1/4] Update PR-Auto-Deploy-V2.yml --- .github/workflows/PR-Auto-Deploy-V2.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 2cebfb650..6f02c599b 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -87,7 +87,7 @@ jobs: fi fi else - auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96") + auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then should=true @@ -498,4 +498,4 @@ jobs: if: always() run: | rm -f ../private.key - continue-on-error: true \ No newline at end of file + continue-on-error: true From 138949caa76862b7e7f42d5cb4da89fe1a25886d Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:57:31 +0000 Subject: [PATCH 2/4] Feature/v2/extract pages (#4828) # Description of Changes - Add the extract pages tool - Componentize our bulk selection logic and warning messaages --- ## 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. --- .../public/locales/en-GB/translation.json | 21 +++++++ .../public/locales/en-US/translation.json | 21 +++++++ .../pageEditor/BulkSelectionPanel.tsx | 25 ++------ .../AdvancedSelectionPanel.tsx | 2 +- .../BulkSelectionPanel.module.css | 19 ++++++ .../bulkSelectionPanel/OperatorsSection.tsx | 2 +- .../shared/PageSelectionSyntaxHint.tsx | 47 ++++++++++++++ .../extractPages/ExtractPagesSettings.tsx | 36 +++++++++++ .../tooltips/useExtractPagesTips.ts | 22 +++++++ .../core/data/useTranslatedToolRegistry.tsx | 9 ++- .../extractPages/useExtractPagesOperation.ts | 59 ++++++++++++++++++ .../extractPages/useExtractPagesParameters.ts | 22 +++++++ frontend/src/core/tools/ExtractPages.tsx | 62 +++++++++++++++++++ .../bulkselection/selectionBuilders.ts} | 4 +- 14 files changed, 325 insertions(+), 26 deletions(-) create mode 100644 frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx create mode 100644 frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx create mode 100644 frontend/src/core/components/tooltips/useExtractPagesTips.ts create mode 100644 frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts create mode 100644 frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts create mode 100644 frontend/src/core/tools/ExtractPages.tsx rename frontend/src/core/{components/pageEditor/bulkSelectionPanel/BulkSelection.ts => utils/bulkselection/selectionBuilders.ts} (98%) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 669c9f505..5d70efc9c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1418,6 +1418,26 @@ }, "submit": "Remove Pages" }, + "extractPages": { + "title": "Extract Pages", + "pageNumbers": { + "label": "Pages to Extract", + "placeholder": "e.g., 1,3,5-8 or odd & 1-10" + }, + "settings": { + "title": "Settings" + }, + "tooltip": { + "description": "Extracts the selected pages into a new PDF, preserving order." + }, + "error": { + "failed": "Failed to extract pages" + }, + "results": { + "title": "Pages Extracted" + }, + "submit": "Extract Pages" + }, "pageSelection": { "tooltip": { "header": { @@ -1494,6 +1514,7 @@ } }, "bulkSelection": { + "syntaxError": "There is a syntax issue. See Page Selection tips for help.", "header": { "title": "Page Selection Guide" }, diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index c92c4c56e..f0b241bf9 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -893,6 +893,26 @@ }, "submit": "Remove Pages" }, + "extractPages": { + "title": "Extract Pages", + "pageNumbers": { + "label": "Pages to Extract", + "placeholder": "e.g., 1,3,5-8 or odd & 1-10" + }, + "settings": { + "title": "Settings" + }, + "tooltip": { + "description": "Extracts the selected pages into a new PDF, preserving order." + }, + "error": { + "failed": "Failed to extract pages" + }, + "results": { + "title": "Pages Extracted" + }, + "submit": "Extract Pages" + }, "pageSelection": { "tooltip": { "header": { @@ -958,6 +978,7 @@ } }, "bulkSelection": { + "syntaxError": "There is a syntax issue. See Page Selection tips for help.", "header": { "title": "Page Selection Guide" }, "syntax": { "title": "Syntax Basics", diff --git a/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx b/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx index 50b304e0b..81d474333 100644 --- a/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx +++ b/frontend/src/core/components/pageEditor/BulkSelectionPanel.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css'; -import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection'; import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput'; import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay'; +import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint'; import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel'; interface BulkSelectionPanelProps { @@ -20,26 +20,9 @@ const BulkSelectionPanel = ({ displayDocument, onUpdatePagesFromCSV, }: BulkSelectionPanelProps) => { - const [syntaxError, setSyntaxError] = useState(null); const [advancedOpened, setAdvancedOpened] = useState(false); const maxPages = displayDocument?.pages?.length ?? 0; - - // Validate input syntax and show lightweight feedback - useEffect(() => { - const text = (csvInput || '').trim(); - if (!text) { - setSyntaxError(null); - return; - } - try { - const { warning } = parseSelectionWithDiagnostics(text, maxPages); - setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null); - } catch { - setSyntaxError('There is a syntax issue. See Page Selection tips for help.'); - } - }, [csvInput, maxPages]); - const handleClear = () => { setCsvInput(''); onUpdatePagesFromCSV(''); @@ -56,10 +39,12 @@ const BulkSelectionPanel = ({ onToggleAdvanced={setAdvancedOpened} /> + + { + const [syntaxError, setSyntaxError] = useState(null); + const { t } = useTranslation(); + + useEffect(() => { + const text = (input || '').trim(); + if (!text) { + setSyntaxError(null); + return; + } + + try { + const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES); + setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null); + } catch { + setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.')); + } + }, [input, maxPages]); + + if (!syntaxError) return null; + + return ( +
+ {syntaxError} +
+ ); +}; + +export default PageSelectionSyntaxHint; + + diff --git a/frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx b/frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx new file mode 100644 index 000000000..4489458db --- /dev/null +++ b/frontend/src/core/components/tools/extractPages/ExtractPagesSettings.tsx @@ -0,0 +1,36 @@ +import { Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; +import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint"; + +interface ExtractPagesSettingsProps { + parameters: ExtractPagesParameters; + onParameterChange: (key: K, value: ExtractPagesParameters[K]) => void; + disabled?: boolean; +} + +const ExtractPagesSettings = ({ parameters, onParameterChange, disabled = false }: ExtractPagesSettingsProps) => { + const { t } = useTranslation(); + + const handleChange = (value: string) => { + onParameterChange('pageNumbers', value); + }; + + return ( + + handleChange(event.currentTarget.value)} + placeholder={t('extractPages.pageNumbers.placeholder', 'e.g., 1,3,5-8 or odd & 1-10')} + disabled={disabled} + required + /> + + + ); +}; + +export default ExtractPagesSettings; + + diff --git a/frontend/src/core/components/tooltips/useExtractPagesTips.ts b/frontend/src/core/components/tooltips/useExtractPagesTips.ts new file mode 100644 index 000000000..7c13b30c2 --- /dev/null +++ b/frontend/src/core/components/tooltips/useExtractPagesTips.ts @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; +import { usePageSelectionTips } from '@app/components/tooltips/usePageSelectionTips'; + +export const useExtractPagesTips = (): TooltipContent => { + const { t } = useTranslation(); + const base = usePageSelectionTips(); + + return { + header: base.header, + tips: [ + { + description: t('extractPages.tooltip.description', 'Extracts the selected pages into a new PDF, preserving order.') + }, + ...(base.tips || []) + ] + }; +}; + +export default useExtractPagesTips; + + diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 0860b5d96..fca3ab9b0 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -79,6 +79,7 @@ import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOver import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation"; +import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; import CompressSettings from "@app/components/tools/compress/CompressSettings"; import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; @@ -105,7 +106,9 @@ import AddPageNumbers from "@app/tools/AddPageNumbers"; import RemoveAnnotations from "@app/tools/RemoveAnnotations"; import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings"; import ExtractImages from "@app/tools/ExtractImages"; +import ExtractPages from "@app/tools/ExtractPages"; import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings"; import AddStampAutomationSettings from "@app/components/tools/addStamp/AddStampAutomationSettings"; import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings"; @@ -474,12 +477,14 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { extractPages: { icon: , name: t("home.extractPages.title", "Extract Pages"), - component: null, + component: ExtractPages, description: t("home.extractPages.desc", "Extract specific pages from a PDF document"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.EXTRACTION, synonyms: getSynonyms(t, "extractPages"), - automationSettings: null, + automationSettings: ExtractPagesSettings, + operationConfig: extractPagesOperationConfig, + endpoints: ["rearrange-pages"], }, extractImages: { icon: , diff --git a/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts b/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts new file mode 100644 index 000000000..086fd65cc --- /dev/null +++ b/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts @@ -0,0 +1,59 @@ +import apiClient from '@app/services/apiClient'; +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; +import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; +import { ExtractPagesParameters, defaultParameters } from '@app/hooks/tools/extractPages/useExtractPagesParameters'; +import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; +import { parseSelection } from '@app/utils/bulkselection/parseSelection'; + +// Convert advanced page selection expression into CSV of explicit one-based page numbers +async function resolveSelectionToCsv(expression: string, file: File): Promise { + // Load PDF to determine max pages + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { disableAutoFetch: true, disableStream: true }); + try { + const maxPages = pdf.numPages; + const pages = parseSelection(expression || '', maxPages); + return pages.join(','); + } finally { + pdfWorkerManager.destroyDocument(pdf); + } +} + +export const extractPagesOperationConfig = { + toolType: ToolType.custom, + operationType: 'extractPages', + customProcessor: async (parameters: ExtractPagesParameters, files: File[]): Promise => { + const outputs: File[] = []; + + for (const file of files) { + // Resolve selection into CSV acceptable by backend + const csv = await resolveSelectionToCsv(parameters.pageNumbers, file); + + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('pageNumbers', csv); + + const response = await apiClient.post('/api/v1/general/rearrange-pages', formData, { responseType: 'blob' }); + + // Name output file with suffix + const base = (file.name || 'document.pdf').replace(/\.[^.]+$/, ''); + const outName = `${base}_extracted_pages.pdf`; + const outFile = new File([response.data], outName, { type: 'application/pdf' }); + outputs.push(outFile); + } + + return outputs; + }, + defaultParameters, +} as const; + +export const useExtractPagesOperation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...extractPagesOperationConfig, + getErrorMessage: createStandardErrorHandler(t('extractPages.error.failed', 'Failed to extract pages')) + }); +}; + + diff --git a/frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts b/frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts new file mode 100644 index 000000000..8ff220166 --- /dev/null +++ b/frontend/src/core/hooks/tools/extractPages/useExtractPagesParameters.ts @@ -0,0 +1,22 @@ +import { BaseParameters } from '@app/types/parameters'; +import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; + +export interface ExtractPagesParameters extends BaseParameters { + pageNumbers: string; +} + +export const defaultParameters: ExtractPagesParameters = { + pageNumbers: '', +}; + +export type ExtractPagesParametersHook = BaseParametersHook; + +export const useExtractPagesParameters = (): ExtractPagesParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'rearrange-pages', + validateFn: (p) => (p.pageNumbers || '').trim().length > 0, + }); +}; + + diff --git a/frontend/src/core/tools/ExtractPages.tsx b/frontend/src/core/tools/ExtractPages.tsx new file mode 100644 index 000000000..402187aa8 --- /dev/null +++ b/frontend/src/core/tools/ExtractPages.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { BaseToolProps, ToolComponent } from "@app/types/tool"; +import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; +import { useExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; +import { useExtractPagesOperation } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; +import useExtractPagesTips from "@app/components/tooltips/useExtractPagesTips"; + +const ExtractPages = (props: BaseToolProps) => { + const { t } = useTranslation(); + const tooltipContent = useExtractPagesTips(); + + const base = useBaseTool( + 'extract-pages', + useExtractPagesParameters, + useExtractPagesOperation, + props + ); + + const settingsContent = ( + + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("extractPages.settings.title", "Settings"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + content: settingsContent, + tooltip: tooltipContent, + }, + ], + executeButton: { + text: t("extractPages.submit", "Extract Pages"), + loadingText: t("loading"), + onClick: base.handleExecute, + isVisible: !base.hasResults, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("extractPages.results.title", "Pages Extracted"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default ExtractPages as ToolComponent; + + diff --git a/frontend/src/core/components/pageEditor/bulkSelectionPanel/BulkSelection.ts b/frontend/src/core/utils/bulkselection/selectionBuilders.ts similarity index 98% rename from frontend/src/core/components/pageEditor/bulkSelectionPanel/BulkSelection.ts rename to frontend/src/core/utils/bulkselection/selectionBuilders.ts index d99b03a51..7d8146089 100644 --- a/frontend/src/core/components/pageEditor/bulkSelectionPanel/BulkSelection.ts +++ b/frontend/src/core/utils/bulkselection/selectionBuilders.ts @@ -1,4 +1,4 @@ -// Pure helper utilities for the BulkSelectionPanel UI +// Pure helper utilities for building and manipulating bulk page selection expressions export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd'; @@ -33,7 +33,7 @@ export function insertOperatorSmart(currentInput: string, op: LogicalOperator): } return `${text} or ${op} `; } - + if (text.length === 0) return `${op} `; // Extract up to the last two operator tokens (words or symbols) from the end From 9440e99227b8028207c414fac5fbb1c33d070f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:33:34 +0100 Subject: [PATCH 3/4] [V2] feat(replaceColor): add CMYK color space conversion option (#4832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Introduced a new "Convert to CMYK" option in Replace Color settings. - Added tooltips for the new CMYK conversion feature. - Updated ReplaceColorParameters to support COLOR_SPACE_CONVERSION option. For backend reference see this PR: #4494 This pull request adds support for converting PDF colors to the CMYK color space in the Replace Color tool, which is especially useful for preparing documents for professional printing. The changes include updates to the user interface, tooltips, and type definitions to accommodate this new option. **Replace Color tool: Add CMYK color space conversion option** * Feature addition: * Added a new `COLOR_SPACE_CONVERSION` option to the `replaceAndInvertOption` parameter in the `ReplaceColorParameters` type, enabling support for CMYK color conversion. * Updated the `ReplaceColorSettings` component to include "Convert to CMYK" as a selectable option in the UI. * User guidance: * Added a new tooltip entry explaining the "Convert to CMYK" feature, describing its purpose and use case for professional printing. ### Front-end image --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] 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. --------- Signed-off-by: Balázs Szücs --- frontend/public/locales/en-GB/translation.json | 7 ++++++- .../components/tools/replaceColor/ReplaceColorSettings.tsx | 4 ++++ .../src/core/components/tooltips/useReplaceColorTips.ts | 6 +++++- .../hooks/tools/replaceColor/useReplaceColorParameters.ts | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 5d70efc9c..1ee4aeac2 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2996,7 +2996,8 @@ "options": { "highContrast": "High contrast", "invertAll": "Invert all colours", - "custom": "Custom" + "custom": "Custom", + "cmyk": "Convert to CMYK" }, "tooltip": { "header": { @@ -3023,6 +3024,10 @@ "text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.", "bullet1": "Text colour - Choose the colour for text elements", "bullet2": "Background colour - Set the background colour for the document" + }, + "cmyk": { + "title": "Convert to CMYK", + "text": "Convert the PDF from RGB colour space to CMYK colour space, optimized for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers." } }, "error": { diff --git a/frontend/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx b/frontend/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx index a49342cd5..d0cdca5ce 100644 --- a/frontend/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx +++ b/frontend/src/core/components/tools/replaceColor/ReplaceColorSettings.tsx @@ -23,6 +23,10 @@ const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false { value: 'CUSTOM_COLOR', label: t('replaceColor.options.custom', 'Custom') + }, + { + value: 'COLOR_SPACE_CONVERSION', + label: t('replaceColor.options.cmyk', 'Convert to CMYK') } ]; diff --git a/frontend/src/core/components/tooltips/useReplaceColorTips.ts b/frontend/src/core/components/tooltips/useReplaceColorTips.ts index 3fa8f7234..a6c3808d1 100644 --- a/frontend/src/core/components/tooltips/useReplaceColorTips.ts +++ b/frontend/src/core/components/tooltips/useReplaceColorTips.ts @@ -34,7 +34,11 @@ export const useReplaceColorTips = (): TooltipContent => { t("replaceColor.tooltip.custom.bullet1", "Text colour - Choose the colour for text elements"), t("replaceColor.tooltip.custom.bullet2", "Background colour - Set the background colour for the document") ] + }, + { + title: t("replaceColor.tooltip.cmyk.title", "Convert to CMYK"), + description: t("replaceColor.tooltip.cmyk.text", "Convert the PDF from RGB colour space to CMYK colour space, optimized for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers.") } ] }; -}; \ No newline at end of file +}; diff --git a/frontend/src/core/hooks/tools/replaceColor/useReplaceColorParameters.ts b/frontend/src/core/hooks/tools/replaceColor/useReplaceColorParameters.ts index a91459715..a0a0099c4 100644 --- a/frontend/src/core/hooks/tools/replaceColor/useReplaceColorParameters.ts +++ b/frontend/src/core/hooks/tools/replaceColor/useReplaceColorParameters.ts @@ -2,7 +2,7 @@ import { BaseParameters } from '@app/types/parameters'; import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; export interface ReplaceColorParameters extends BaseParameters { - replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION'; + replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION' | 'COLOR_SPACE_CONVERSION'; highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK'; textColor: string; backGroundColor: string; From f5c67a3239d2d932d3c6c02a6c68769703dee7c7 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 6 Nov 2025 15:42:22 +0000 Subject: [PATCH 4/4] Login Refresh Fix (#4779) Main Issues Fixed: 1. Tools Disabled on Initial Login (Required Page Refresh) Problem: After successful login, all PDF tools appeared grayed out/disabled until the user refreshed the page. Root Cause: Race condition where tools checked endpoint availability before JWT was stored in localStorage. Fix: - Implemented optimistic defaults in useEndpointConfig - assumes endpoints are enabled when no JWT exists - Added JWT availability event system (jwt-available event) to notify components when authentication is ready - Tools now remain enabled during auth initialization instead of defaulting to disabled 2. Session Lost on Page Refresh (Immediate Logout) Problem: Users were immediately logged out when refreshing the page, losing their authenticated session. Root Causes: - Spring Security form login was redirecting API calls to /login with 302 responses instead of returning JSON - /api/v1/auth/me endpoint was incorrectly in the permitAll list - JWT filter wasn't allowing /api/v1/config endpoints without authentication Fixes: - Backend: Disabled form login in v2/JWT mode by adding && !v2Enabled condition to form login configuration - Backend: Removed /api/v1/auth/me from permitAll list - it now requires authentication - Backend: Added /api/v1/config to public endpoints in JWT filter - Backend: Configured proper exception handling for API endpoints to return JSON (401) instead of HTML redirects (302) 3. Multiple Duplicate API Calls Problem: After login, /app-config was called 5+ times, /endpoints-enabled and /me called multiple times, causing unnecessary network traffic. Root Cause: Multiple React components each had their own instance of useAppConfig and useEndpointConfig hooks, each fetching data independently. Fix: - Frontend: Created singleton AppConfigContext provider to ensure only one global config fetch - Frontend: Added global caching to useEndpointConfig with module-level cache variables - Frontend: Implemented fetch deduplication with fetchCount tracking and globalFetchedSets - Result: Reduced API calls from 5+ to 1-2 per endpoint (2 in dev due to React StrictMode) Additional Improvements: CORS Configuration - Added flexible CORS configuration matching SaaS pattern - Explicitly allows localhost development ports (3000, 5173, 5174, etc.) - No hardcoded URLs in application.properties Security Handlers Integration - Added IP-based account locking without dependency on form login - Preserved audit logging with @Audited annotations Key Code Changes: Backend Files: - SecurityConfiguration.java - Disabled form login for v2, added CORS config - JwtAuthenticationFilter.java - Added /api/v1/config to public endpoints - JwtAuthenticationEntryPoint.java - Returns JSON for API requests Frontend Files: - AppConfigContext.tsx - New singleton context for app configuration - useEndpointConfig.ts - Added global caching and deduplication - UseSession.tsx - Removed redundant config checking - Various hooks - Updated to use context providers instead of direct fetching --------- Signed-off-by: dependabot[bot] Signed-off-by: stirlingbot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Connor Yoh --- .../software/common/util/RequestUriUtils.java | 29 ++++ .../security/JwtAuthenticationEntryPoint.java | 16 +- .../configuration/SecurityConfiguration.java | 153 ++++++++++++----- .../controller/api/AuthController.java | 49 ++++-- .../filter/JwtAuthenticationFilter.java | 13 +- .../filter/UserAuthenticationFilter.java | 29 +++- ...tomOAuth2AuthenticationSuccessHandler.java | 158 ++++++++++++++++-- .../security/oauth2/OAuth2Configuration.java | 7 +- ...stomSaml2AuthenticationSuccessHandler.java | 124 +++++++++++++- .../JwtAuthenticationEntryPointTest.java | 2 + frontend/package-lock.json | 42 ++++- .../src/core/contexts/AppConfigContext.tsx | 65 +++++-- frontend/src/core/hooks/useEndpointConfig.ts | 135 +++++++++++---- frontend/src/core/hooks/useToolManagement.tsx | 10 +- frontend/src/proprietary/auth/UseSession.tsx | 19 +-- .../src/proprietary/auth/springAuthClient.ts | 70 +++++++- .../src/proprietary/routes/AuthCallback.tsx | 3 + frontend/vite.config.ts | 33 ++-- 18 files changed, 760 insertions(+), 197 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 239976b66..321606186 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -49,4 +49,33 @@ public class RequestUriUtils { || requestURI.startsWith("/fonts") || requestURI.startsWith("/pdfjs")); } + + /** + * Checks if the request URI is a public authentication endpoint that doesn't require + * authentication. This includes login, signup, OAuth callbacks, and public config endpoints. + * + * @param requestURI The full request URI + * @param contextPath The servlet context path + * @return true if the endpoint is public and doesn't require authentication + */ + public static boolean isPublicAuthEndpoint(String requestURI, String contextPath) { + // Remove context path from URI to normalize path matching + String trimmedUri = + requestURI.startsWith(contextPath) + ? requestURI.substring(contextPath.length()) + : requestURI; + + // Public auth endpoints that don't require authentication + return trimmedUri.startsWith("/login") + || trimmedUri.startsWith("/auth/") + || trimmedUri.startsWith("/oauth2") + || trimmedUri.startsWith("/saml2") + || trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback + || trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint + || trimmedUri.startsWith("/api/v1/auth/login") + || trimmedUri.startsWith("/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith("/v1/api-docs") + || trimmedUri.contains("/v1/api-docs"); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java index 6805bcb54..479b544ad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java @@ -17,6 +17,20 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { HttpServletResponse response, AuthenticationException authException) throws IOException { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + String contextPath = request.getContextPath(); + String requestURI = request.getRequestURI(); + + // For API requests, return JSON error + if (requestURI.startsWith(contextPath + "/api/")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + String message = + authException != null ? authException.getMessage() : "Authentication required"; + response.getWriter().write("{\"error\":\"" + message + "\"}"); + } else { + // For non-API requests, use default behavior + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 92def884f..010c15e29 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.configuration; +import java.util.List; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; @@ -28,11 +29,15 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.CustomAuthenticationFailureHandler; import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler; import stirling.software.proprietary.security.CustomLogoutSuccessHandler; @@ -67,6 +72,7 @@ public class SecurityConfiguration { private final boolean loginEnabledValue; private final boolean runningProOrHigher; + private final ApplicationProperties applicationProperties; private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; @@ -86,6 +92,7 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, + ApplicationProperties applicationProperties, ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, JwtServiceInterface jwtService, @@ -102,6 +109,7 @@ public class SecurityConfiguration { this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; this.appConfig = appConfig; + this.applicationProperties = applicationProperties; this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; this.jwtService = jwtService; @@ -120,7 +128,79 @@ public class SecurityConfiguration { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public CorsConfigurationSource corsConfigurationSource() { + // Read CORS allowed origins from settings + if (applicationProperties.getSystem() != null + && applicationProperties.getSystem().getCorsAllowedOrigins() != null + && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + + List allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); + + CorsConfiguration cfg = new CorsConfiguration(); + + // Use setAllowedOriginPatterns for better wildcard and port support + cfg.setAllowedOriginPatterns(allowedOrigins); + log.debug( + "CORS configured with allowed origin patterns from settings.yml: {}", + allowedOrigins); + + // Set allowed methods explicitly (including OPTIONS for preflight) + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + + // Set allowed headers explicitly + cfg.setAllowedHeaders( + List.of( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN")); + + // Set exposed headers (headers that the browser can access) + cfg.setExposedHeaders( + List.of( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type")); + + // Allow credentials (cookies, authorization headers) + cfg.setAllowCredentials(true); + + // Set max age for preflight cache + cfg.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } else { + // No CORS origins configured - return null to disable CORS processing entirely + // This avoids empty CORS policy that unexpectedly rejects preflights + log.info( + "CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)"); + return null; + } + } + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + @Lazy IPRateLimitingFilter rateLimitingFilter, + @Lazy JwtAuthenticationFilter jwtAuthenticationFilter) + throws Exception { + // Enable CORS only if we have configured origins + CorsConfigurationSource corsSource = corsConfigurationSource(); + if (corsSource != null) { + http.cors(cors -> cors.configurationSource(corsSource)); + } else { + // Explicitly disable CORS when no origins are configured + http.cors(cors -> cors.disable()); + } + if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { http.csrf(CsrfConfigurer::disable); } @@ -130,12 +210,8 @@ public class SecurityConfiguration { http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore( - rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - - if (v2Enabled) { - http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); - } + .addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -195,6 +271,18 @@ public class SecurityConfiguration { }); http.authenticationProvider(daoAuthenticationProvider()); http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); + + // Configure exception handling for API endpoints + http.exceptionHandling( + exceptions -> + exceptions.defaultAuthenticationEntryPointFor( + jwtAuthenticationEntryPoint, + request -> { + String contextPath = request.getContextPath(); + String requestURI = request.getRequestURI(); + return requestURI.startsWith(contextPath + "/api/"); + })); + http.logout( logout -> logout.logoutRequestMatcher( @@ -228,43 +316,12 @@ public class SecurityConfiguration { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Remove the context path from the URI - String trimmedUri = - uri.startsWith(contextPath) - ? uri.substring( - contextPath.length()) - : uri; - return trimmedUri.startsWith("/login") - || trimmedUri.startsWith("/oauth") - || trimmedUri.startsWith("/oauth2") - || trimmedUri.startsWith("/saml2") - || trimmedUri.endsWith(".svg") - || trimmedUri.startsWith("/register") - || trimmedUri.startsWith("/signup") - || trimmedUri.startsWith("/auth/callback") - || trimmedUri.startsWith("/error") - || trimmedUri.startsWith("/images/") - || trimmedUri.startsWith("/public/") - || trimmedUri.startsWith("/css/") - || trimmedUri.startsWith("/fonts/") - || trimmedUri.startsWith("/js/") - || trimmedUri.startsWith("/pdfjs/") - || trimmedUri.startsWith("/pdfjs-legacy/") - || trimmedUri.startsWith("/favicon") - || trimmedUri.startsWith( - "/api/v1/info/status") - || trimmedUri.startsWith("/api/v1/config") - || trimmedUri.startsWith( - "/api/v1/auth/register") - || trimmedUri.startsWith( - "/api/v1/user/register") - || trimmedUri.startsWith( - "/api/v1/auth/login") - || trimmedUri.startsWith( - "/api/v1/auth/refresh") - || trimmedUri.startsWith("/api/v1/auth/me") - || trimmedUri.startsWith("/v1/api-docs") - || uri.contains("/v1/api-docs"); + // Check if it's a public auth endpoint or static + // resource + return RequestUriUtils.isStaticResource( + contextPath, uri) + || RequestUriUtils.isPublicAuthEndpoint( + uri, contextPath); }) .permitAll() .anyRequest() @@ -333,8 +390,12 @@ public class SecurityConfiguration { .saml2Login( saml2 -> { try { - saml2.loginPage("/saml2") - .relyingPartyRegistrationRepository( + // Only set login page for v1/Thymeleaf mode + if (!v2Enabled) { + saml2.loginPage("/saml2"); + } + + saml2.relyingPartyRegistrationRepository( saml2RelyingPartyRegistrations) .authenticationManager( new ProviderManager(authenticationProvider)) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 0dd8ee4bf..de6428554 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -21,11 +21,15 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; /** REST API Controller for authentication operations. */ @@ -39,6 +43,7 @@ public class AuthController { private final UserService userService; private final JwtServiceInterface jwtService; private final CustomUserDetailsService userDetailsService; + private final LoginAttemptService loginAttemptService; /** * Login endpoint - replaces Supabase signInWithPassword @@ -49,8 +54,11 @@ public class AuthController { */ @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/login") + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public ResponseEntity login( - @RequestBody UsernameAndPass request, HttpServletResponse response) { + @RequestBody UsernameAndPass request, + HttpServletRequest httpRequest, + HttpServletResponse response) { try { // Validate input parameters if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { @@ -67,20 +75,30 @@ public class AuthController { .body(Map.of("error", "Password is required")); } - log.debug("Login attempt for user: {}", request.getUsername()); + String username = request.getUsername().trim(); + String ip = httpRequest.getRemoteAddr(); - UserDetails userDetails = - userDetailsService.loadUserByUsername(request.getUsername().trim()); + // Check if account is blocked due to too many failed attempts + if (loginAttemptService.isBlocked(username)) { + log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Account is locked due to too many failed attempts")); + } + + log.debug("Login attempt for user: {} from IP: {}", username, ip); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); User user = (User) userDetails; if (!userService.isPasswordCorrect(user, request.getPassword())) { - log.warn("Invalid password for user: {}", request.getUsername()); + log.warn("Invalid password for user: {} from IP: {}", username, ip); + loginAttemptService.loginFailed(username); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid credentials")); } if (!user.isEnabled()) { - log.warn("Disabled user attempted login: {}", request.getUsername()); + log.warn("Disabled user attempted login: {} from IP: {}", username, ip); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "User account is disabled")); } @@ -91,7 +109,9 @@ public class AuthController { String token = jwtService.generateToken(user.getUsername(), claims); - log.info("Login successful for user: {}", request.getUsername()); + // Record successful login + loginAttemptService.loginSucceeded(username); + log.info("Login successful for user: {} from IP: {}", username, ip); return ResponseEntity.ok( Map.of( @@ -99,11 +119,15 @@ public class AuthController { "session", Map.of("access_token", token, "expires_in", 3600))); } catch (UsernameNotFoundException e) { - log.warn("User not found: {}", request.getUsername()); + String username = request.getUsername(); + log.warn("User not found: {}", username); + loginAttemptService.loginFailed(username); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid username or password")); } catch (AuthenticationException e) { - log.error("Authentication failed for user: {}", request.getUsername(), e); + String username = request.getUsername(); + log.error("Authentication failed for user: {}", username, e); + loginAttemptService.loginFailed(username); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid credentials")); } catch (Exception e) { @@ -228,11 +252,4 @@ public class AuthController { return userMap; } - - // =========================== - // Request/Response DTOs - // =========================== - - /** Login request DTO */ - public record LoginRequest(String email, String password) {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index d6a34264f..ace7d3318 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.filter; +import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint; import static stirling.software.common.util.RequestUriUtils.isStaticResource; import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; @@ -80,17 +81,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String requestURI = request.getRequestURI(); String contextPath = request.getContextPath(); - // Public auth endpoints that don't require JWT - boolean isPublicAuthEndpoint = - requestURI.startsWith(contextPath + "/login") - || requestURI.startsWith(contextPath + "/signup") - || requestURI.startsWith(contextPath + "/auth/") - || requestURI.startsWith(contextPath + "/oauth2") - || requestURI.startsWith(contextPath + "/api/v1/auth/login") - || requestURI.startsWith(contextPath + "/api/v1/auth/register") - || requestURI.startsWith(contextPath + "/api/v1/auth/refresh"); - - if (!isPublicAuthEndpoint) { + if (!isPublicAuthEndpoint(requestURI, contextPath)) { // For API requests, return 401 JSON String acceptHeader = request.getHeader("Accept"); if (requestURI.startsWith(contextPath + "/api/") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 6a32511b0..6265281d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.security.filter; +import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint; + import java.io.IOException; import java.util.List; import java.util.Optional; @@ -105,11 +107,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } } - // If we still don't have any authentication, deny the request + // If we still don't have any authentication, check if it's a public endpoint. If not, deny the request if (authentication == null || !authentication.isAuthenticated()) { String method = request.getMethod(); String contextPath = request.getContextPath(); + // Allow public auth endpoints to pass through without authentication + if (isPublicAuthEndpoint(requestURI, contextPath)) { + filterChain.doFilter(request, response); + return; + } + if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) { response.sendRedirect(contextPath + "/login"); // redirect to the login page } else { @@ -200,6 +208,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) { + // Remove context path from URI to normalize path matching + String trimmedUri = + requestURI.startsWith(contextPath) + ? requestURI.substring(contextPath.length()) + : requestURI; + + // Public auth endpoints that don't require authentication + return trimmedUri.startsWith("/login") + || trimmedUri.startsWith("/auth/") + || trimmedUri.startsWith("/oauth2") + || trimmedUri.startsWith("/saml2") + || trimmedUri.startsWith("/api/v1/auth/login") + || trimmedUri.startsWith("/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/logout"); + } + private enum UserLoginType { USERDETAILS("UserDetails"), OAUTH2USER("OAuth2User"), @@ -225,7 +250,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String contextPath = request.getContextPath(); String[] permitAllPatterns = { contextPath + "/login", - contextPath + "/signup", contextPath + "/register", contextPath + "/error", contextPath + "/images/", @@ -237,7 +261,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/pdfjs-legacy/", contextPath + "/api/v1/info/status", contextPath + "/api/v1/auth/login", - contextPath + "/api/v1/auth/register", contextPath + "/api/v1/auth/refresh", contextPath + "/api/v1/auth/me", contextPath + "/site.webmanifest" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 2afc43443..d2e03a04e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -4,9 +4,14 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA import static stirling.software.proprietary.security.model.AuthenticationType.SSO; import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.Map; +import java.util.Optional; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -16,6 +21,7 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti import org.springframework.security.web.savedrequest.SavedRequest; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; @@ -37,6 +43,9 @@ import stirling.software.proprietary.security.service.UserService; public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path"; + private static final String DEFAULT_CALLBACK_PATH = "/auth/callback"; + private final LoginAttemptService loginAttemptService; private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; @@ -119,7 +128,8 @@ public class CustomOAuth2AuthenticationSuccessHandler authentication, Map.of("authType", AuthenticationType.OAUTH2)); // Build context-aware redirect URL based on the original request - String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt); + String redirectUrl = + buildContextAwareRedirectUrl(request, response, contextPath, jwt); response.sendRedirect(redirectUrl); } else { @@ -149,30 +159,110 @@ public class CustomOAuth2AuthenticationSuccessHandler * Builds a context-aware redirect URL based on the request's origin * * @param request The HTTP request + * @param response HTTP response (used to clear redirect cookies) * @param contextPath The application context path * @param jwt The JWT token to include * @return The appropriate redirect URL */ private String buildContextAwareRedirectUrl( - HttpServletRequest request, String contextPath, String jwt) { - // Try to get the origin from the Referer header first + HttpServletRequest request, + HttpServletResponse response, + String contextPath, + String jwt) { + String redirectPath = resolveRedirectPath(request, contextPath); + String origin = + resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + clearRedirectCookie(response); + return origin + redirectPath + "#access_token=" + jwt; + } + + private String resolveRedirectPath(HttpServletRequest request, String contextPath) { + return extractRedirectPathFromCookie(request) + .filter(path -> path.startsWith("/")) + .orElseGet(() -> defaultCallbackPath(contextPath)); + } + + private Optional extractRedirectPathFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) { + String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim(); + if (!value.isEmpty()) { + return Optional.of(value); + } + } + } + return Optional.empty(); + } + + private String defaultCallbackPath(String contextPath) { + if (contextPath == null + || contextPath.isBlank() + || "/".equals(contextPath) + || "\\".equals(contextPath)) { + return DEFAULT_CALLBACK_PATH; + } + return contextPath + DEFAULT_CALLBACK_PATH; + } + + private Optional resolveForwardedOrigin(HttpServletRequest request) { + String forwardedHostHeader = request.getHeader("X-Forwarded-Host"); + if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) { + return Optional.empty(); + } + String host = forwardedHostHeader.split(",")[0].trim(); + if (host.isEmpty()) { + return Optional.empty(); + } + + String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto"); + String proto = + (forwardedProtoHeader == null || forwardedProtoHeader.isBlank()) + ? request.getScheme() + : forwardedProtoHeader.split(",")[0].trim(); + + if (!host.contains(":")) { + String forwardedPort = request.getHeader("X-Forwarded-Port"); + if (forwardedPort != null + && !forwardedPort.isBlank() + && !isDefaultPort(proto, forwardedPort.trim())) { + host = host + ":" + forwardedPort.trim(); + } + } + return Optional.of(proto + "://" + host); + } + + private Optional resolveOriginFromReferer(HttpServletRequest request) { String referer = request.getHeader("Referer"); if (referer != null && !referer.isEmpty()) { try { java.net.URL refererUrl = new java.net.URL(referer); - String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); - if (refererUrl.getPort() != -1 - && refererUrl.getPort() != 80 - && refererUrl.getPort() != 443) { - origin += ":" + refererUrl.getPort(); + String refererHost = refererUrl.getHost().toLowerCase(); + + if (!isOAuthProviderDomain(refererHost)) { + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return Optional.of(origin); } - return origin + "/auth/callback#access_token=" + jwt; } catch (java.net.MalformedURLException e) { - // Fall back to other methods if referer is malformed + // ignore and fall back } } + return Optional.empty(); + } - // Fall back to building from request host/port + private String buildOriginFromRequest(HttpServletRequest request) { String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); @@ -180,12 +270,50 @@ public class CustomOAuth2AuthenticationSuccessHandler StringBuilder origin = new StringBuilder(); origin.append(scheme).append("://").append(serverName); - // Only add port if it's not the default port for the scheme - if ((!"http".equals(scheme) || serverPort != 80) - && (!"https".equals(scheme) || serverPort != 443)) { + if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80) + && (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) { origin.append(":").append(serverPort); } - return origin.toString() + "/auth/callback#access_token=" + jwt; + return origin.toString(); + } + + private boolean isDefaultPort(String scheme, String port) { + if (port == null) { + return true; + } + try { + int parsedPort = Integer.parseInt(port); + return ("http".equalsIgnoreCase(scheme) && parsedPort == 80) + || ("https".equalsIgnoreCase(scheme) && parsedPort == 443); + } catch (NumberFormatException e) { + return false; + } + } + + private void clearRedirectCookie(HttpServletResponse response) { + ResponseCookie cookie = + ResponseCookie.from(SPA_REDIRECT_COOKIE, "") + .path("/") + .sameSite("Lax") + .maxAge(0) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + /** + * Checks if the given hostname belongs to a known OAuth provider. + * + * @param hostname The hostname to check + * @return true if it's an OAuth provider domain, false otherwise + */ + private boolean isOAuthProviderDomain(String hostname) { + return hostname.contains("google.com") + || hostname.contains("googleapis.com") + || hostname.contains("github.com") + || hostname.contains("microsoft.com") + || hostname.contains("microsoftonline.com") + || hostname.contains("linkedin.com") + || hostname.contains("apple.com"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index cd04d6da0..a053c1ead 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -165,12 +165,7 @@ public class OAuth2Configuration { githubClient.getUseAsUsername()); boolean isValid = validateProvider(github); - log.info( - "GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})", - isValid, - githubClient.getClientId(), - githubClient.getClientSecret() != null ? "***" : "null", - githubClient.getScopes()); + log.info("Initialised GitHub OAuth2 provider"); return isValid ? Optional.of( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index e7a47a391..af6d284cf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -4,15 +4,21 @@ import static stirling.software.proprietary.security.model.AuthenticationType.SA import static stirling.software.proprietary.security.model.AuthenticationType.SSO; import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.Map; +import java.util.Optional; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; @@ -36,6 +42,9 @@ import stirling.software.proprietary.security.service.UserService; public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path"; + private static final String DEFAULT_CALLBACK_PATH = "/auth/callback"; + private LoginAttemptService loginAttemptService; private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; @@ -148,7 +157,7 @@ public class CustomSaml2AuthenticationSuccessHandler // Build context-aware redirect URL based on the original request String redirectUrl = - buildContextAwareRedirectUrl(request, contextPath, jwt); + buildContextAwareRedirectUrl(request, response, contextPath, jwt); response.sendRedirect(redirectUrl); } else { @@ -177,8 +186,81 @@ public class CustomSaml2AuthenticationSuccessHandler * @return The appropriate redirect URL */ private String buildContextAwareRedirectUrl( - HttpServletRequest request, String contextPath, String jwt) { - // Try to get the origin from the Referer header first + HttpServletRequest request, + HttpServletResponse response, + String contextPath, + String jwt) { + String redirectPath = resolveRedirectPath(request, contextPath); + String origin = + resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + clearRedirectCookie(response); + return origin + redirectPath + "#access_token=" + jwt; + } + + private String resolveRedirectPath(HttpServletRequest request, String contextPath) { + return extractRedirectPathFromCookie(request) + .filter(path -> path.startsWith("/")) + .orElseGet(() -> defaultCallbackPath(contextPath)); + } + + private Optional extractRedirectPathFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) { + String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim(); + if (!value.isEmpty()) { + return Optional.of(value); + } + } + } + return Optional.empty(); + } + + private String defaultCallbackPath(String contextPath) { + if (contextPath == null + || contextPath.isBlank() + || "/".equals(contextPath) + || "\\".equals(contextPath)) { + return DEFAULT_CALLBACK_PATH; + } + return contextPath + DEFAULT_CALLBACK_PATH; + } + + private Optional resolveForwardedOrigin(HttpServletRequest request) { + String forwardedHostHeader = request.getHeader("X-Forwarded-Host"); + if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) { + return Optional.empty(); + } + String host = forwardedHostHeader.split(",")[0].trim(); + if (host.isEmpty()) { + return Optional.empty(); + } + + String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto"); + String proto = + (forwardedProtoHeader == null || forwardedProtoHeader.isBlank()) + ? request.getScheme() + : forwardedProtoHeader.split(",")[0].trim(); + + if (!host.contains(":")) { + String forwardedPort = request.getHeader("X-Forwarded-Port"); + if (forwardedPort != null + && !forwardedPort.isBlank() + && !isDefaultPort(proto, forwardedPort.trim())) { + host = host + ":" + forwardedPort.trim(); + } + } + return Optional.of(proto + "://" + host); + } + + private Optional resolveOriginFromReferer(HttpServletRequest request) { String referer = request.getHeader("Referer"); if (referer != null && !referer.isEmpty()) { try { @@ -189,14 +271,16 @@ public class CustomSaml2AuthenticationSuccessHandler && refererUrl.getPort() != 443) { origin += ":" + refererUrl.getPort(); } - return origin + "/auth/callback#access_token=" + jwt; + return Optional.of(origin); } catch (java.net.MalformedURLException e) { log.debug( "Malformed referer URL: {}, falling back to request-based origin", referer); } } + return Optional.empty(); + } - // Fall back to building from request host/port + private String buildOriginFromRequest(HttpServletRequest request) { String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); @@ -204,12 +288,34 @@ public class CustomSaml2AuthenticationSuccessHandler StringBuilder origin = new StringBuilder(); origin.append(scheme).append("://").append(serverName); - // Only add port if it's not the default port for the scheme - if ((!"http".equals(scheme) || serverPort != 80) - && (!"https".equals(scheme) || serverPort != 443)) { + if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80) + && (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) { origin.append(":").append(serverPort); } - return origin + "/auth/callback#access_token=" + jwt; + return origin.toString(); + } + + private boolean isDefaultPort(String scheme, String port) { + if (port == null) { + return true; + } + try { + int parsedPort = Integer.parseInt(port); + return ("http".equalsIgnoreCase(scheme) && parsedPort == 80) + || ("https".equalsIgnoreCase(scheme) && parsedPort == 443); + } catch (NumberFormatException e) { + return false; + } + } + + private void clearRedirectCookie(HttpServletResponse response) { + ResponseCookie cookie = + ResponseCookie.from(SPA_REDIRECT_COOKIE, "") + .path("/") + .sameSite("Lax") + .maxAge(0) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java index a47f45318..0fcd0f4c6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java @@ -29,6 +29,8 @@ class JwtAuthenticationEntryPointTest { @Test void testCommence() throws IOException { String errorMessage = "Authentication failed"; + + when(request.getRequestURI()).thenReturn("/redact"); when(authException.getMessage()).thenReturn(errorMessage); jwtAuthenticationEntryPoint.commence(request, response, authException); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db900aeea..43ee35e16 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -441,6 +441,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -487,6 +488,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -510,6 +512,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -593,6 +596,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -609,6 +613,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -626,6 +631,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -662,6 +668,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -696,6 +703,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -732,6 +740,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -807,6 +816,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -962,6 +972,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1005,6 +1016,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2035,6 +2047,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2085,6 +2098,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2152,6 +2166,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3835,6 +3850,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4158,6 +4174,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4168,6 +4185,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4228,6 +4246,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4941,7 +4960,6 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4951,7 +4969,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4962,7 +4979,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4975,7 +4991,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" @@ -5002,6 +5017,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5686,6 +5702,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6731,7 +6748,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7126,6 +7144,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7296,6 +7315,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8618,6 +8638,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9425,6 +9446,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -11201,6 +11223,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11480,6 +11503,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11852,6 +11876,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11861,6 +11886,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13531,6 +13557,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13832,6 +13859,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13914,6 +13942,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14118,6 +14147,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14269,6 +14299,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14282,6 +14313,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 0fdd64d9e..5bb05a50b 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; -import { useRequestHeaders } from '@app/hooks/useRequestHeaders'; +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import apiClient from '@app/services/apiClient'; export interface AppConfig { baseUrl?: string; @@ -36,52 +36,84 @@ interface AppConfigContextValue { refetch: () => Promise; } -// Create context -const AppConfigContext = createContext(undefined); +const AppConfigContext = createContext({ + config: null, + loading: true, + error: null, + refetch: async () => {}, +}); /** * Provider component that fetches and provides app configuration * Should be placed at the top level of the app, before any components that need config */ -export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const headers = useRequestHeaders(); + const [fetchCount, setFetchCount] = useState(0); + + const fetchConfig = async (force = false) => { + // Prevent duplicate fetches unless forced + if (!force && fetchCount > 0) { + console.debug('[AppConfig] Already fetched, skipping'); + return; + } - const fetchConfig = async () => { try { setLoading(true); setError(null); - const response = await fetch('/api/v1/config/app-config', { - headers, - }); + // apiClient automatically adds JWT header if available via interceptors + const response = await apiClient.get('/api/v1/config/app-config'); + const data = response.data; - if (!response.ok) { - throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`); + console.debug('[AppConfig] Config fetched successfully:', data); + setConfig(data); + setFetchCount(prev => prev + 1); + } catch (err: any) { + // On 401 (not authenticated), use default config with login enabled + // This allows the app to work even without authentication + if (err.response?.status === 401) { + console.debug('[AppConfig] 401 error - using default config (login enabled)'); + setConfig({ enableLogin: true }); + setLoading(false); + return; } - const data: AppConfig = await response.json(); - setConfig(data); - } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); console.error('[AppConfig] Failed to fetch app config:', err); + // On error, assume login is enabled (safe default) + setConfig({ enableLogin: true }); } finally { setLoading(false); } }; useEffect(() => { + // Always try to fetch config to check if login is disabled + // The endpoint should be public and return proper JSON fetchConfig(); }, []); + // Listen for JWT availability (triggered on login/signup) + useEffect(() => { + const handleJwtAvailable = () => { + console.debug('[AppConfig] JWT available event - refetching config'); + // Force refetch with JWT + fetchConfig(true); + }; + + window.addEventListener('jwt-available', handleJwtAvailable); + return () => window.removeEventListener('jwt-available', handleJwtAvailable); + }, []); + const value: AppConfigContextValue = { config, loading, error, - refetch: fetchConfig, + refetch: () => fetchConfig(true), }; return ( @@ -104,4 +136,3 @@ export function useAppConfig(): AppConfigContextValue { return context; } - diff --git a/frontend/src/core/hooks/useEndpointConfig.ts b/frontend/src/core/hooks/useEndpointConfig.ts index 3c418aac2..83bb34b57 100644 --- a/frontend/src/core/hooks/useEndpointConfig.ts +++ b/frontend/src/core/hooks/useEndpointConfig.ts @@ -1,8 +1,13 @@ import { useState, useEffect } from 'react'; -import { useRequestHeaders } from '@app/hooks/useRequestHeaders'; +import apiClient from '@app/services/apiClient'; + +// Track globally fetched endpoint sets to prevent duplicate fetches across components +const globalFetchedSets = new Set(); +const globalEndpointCache: Record = {}; /** * Hook to check if a specific endpoint is enabled + * This wraps the context for single endpoint checks */ export function useEndpointEnabled(endpoint: string): { enabled: boolean | null; @@ -13,7 +18,6 @@ export function useEndpointEnabled(endpoint: string): { const [enabled, setEnabled] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const headers = useRequestHeaders(); const fetchEndpointStatus = async () => { if (!endpoint) { @@ -26,15 +30,8 @@ export function useEndpointEnabled(endpoint: string): { setLoading(true); setError(null); - const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, { - headers, - }); - - if (!response.ok) { - throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`); - } - - const isEnabled: boolean = await response.json(); + const response = await apiClient.get(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`); + const isEnabled = response.data; setEnabled(isEnabled); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; @@ -69,43 +66,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const [endpointStatus, setEndpointStatus] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const headers = useRequestHeaders(); - const fetchAllEndpointStatuses = async () => { + const fetchAllEndpointStatuses = async (force = false) => { + const endpointsKey = [...endpoints].sort().join(','); + + // Skip if we already fetched these exact endpoints globally + if (!force && globalFetchedSets.has(endpointsKey)) { + console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache'); + const cachedStatus = endpoints.reduce((acc, endpoint) => { + if (endpoint in globalEndpointCache) { + acc[endpoint] = globalEndpointCache[endpoint]; + } + return acc; + }, {} as Record); + setEndpointStatus(cachedStatus); + setLoading(false); + return; + } if (!endpoints || endpoints.length === 0) { setEndpointStatus({}); setLoading(false); return; } + // Check if JWT exists - if not, optimistically enable all endpoints + const hasJwt = !!localStorage.getItem('stirling_jwt'); + if (!hasJwt) { + console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints'); + const optimisticStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = true; + return acc; + }, {} as Record); + setEndpointStatus(optimisticStatus); + setLoading(false); + return; + } + try { setLoading(true); setError(null); - // Use batch API for efficiency - const endpointsParam = endpoints.join(','); - - const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, { - headers, - }); - - if (!response.ok) { - throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); + // Check which endpoints we haven't fetched yet + const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache)); + if (newEndpoints.length === 0) { + console.debug('[useEndpointConfig] All endpoints already in global cache'); + const cachedStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = globalEndpointCache[endpoint]; + return acc; + }, {} as Record); + setEndpointStatus(cachedStatus); + globalFetchedSets.add(endpointsKey); + setLoading(false); + return; } - const statusMap: Record = await response.json(); - setEndpointStatus(statusMap); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - setError(errorMessage); - console.error('Failed to check multiple endpoints:', err); + // Use batch API for efficiency - only fetch new endpoints + const endpointsParam = newEndpoints.join(','); - // Fallback: assume all endpoints are disabled on error - const fallbackStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = false; + const response = await apiClient.get>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); + const statusMap = response.data; + + // Update global cache with new results + Object.assign(globalEndpointCache, statusMap); + + // Get all requested endpoints from cache (including previously cached ones) + const fullStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache return acc; }, {} as Record); - setEndpointStatus(fallbackStatus); + + setEndpointStatus(fullStatus); + globalFetchedSets.add(endpointsKey); + } catch (err: any) { + // On 401 (auth error), use optimistic fallback instead of disabling + if (err.response?.status === 401) { + console.warn('[useEndpointConfig] 401 error - using optimistic fallback'); + const optimisticStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = true; + globalEndpointCache[endpoint] = true; // Cache the optimistic value + return acc; + }, {} as Record); + setEndpointStatus(optimisticStatus); + setLoading(false); + return; + } + + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; + setError(errorMessage); + console.error('[EndpointConfig] Failed to check multiple endpoints:', err); + + // Fallback: assume all endpoints are enabled on error (optimistic) + const optimisticStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = true; + return acc; + }, {} as Record); + setEndpointStatus(optimisticStatus); } finally { setLoading(false); } @@ -115,10 +170,24 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { fetchAllEndpointStatuses(); }, [endpoints.join(',')]); // Re-run when endpoints array changes + // Listen for JWT availability (triggered on login/signup) + useEffect(() => { + const handleJwtAvailable = () => { + console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth'); + // Clear the global cache to allow refetch with JWT + globalFetchedSets.clear(); + Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]); + fetchAllEndpointStatuses(true); + }; + + window.addEventListener('jwt-available', handleJwtAvailable); + return () => window.removeEventListener('jwt-available', handleJwtAvailable); + }, [endpoints.join(',')]); + return { endpointStatus, loading, error, - refetch: fetchAllEndpointStatuses, + refetch: () => fetchAllEndpointStatuses(true), }; } diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx index fcc543d27..3f5676824 100644 --- a/frontend/src/core/hooks/useToolManagement.tsx +++ b/frontend/src/core/hooks/useToolManagement.tsx @@ -24,10 +24,18 @@ export const useToolManagement = (): ToolManagementResult => { const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { + // Keep tools enabled during loading (optimistic UX) if (endpointsLoading) return true; + const tool = baseRegistry[toolKey as ToolId]; const endpoints = tool?.endpoints || []; - return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); + + // Tools without endpoints are always available + if (endpoints.length === 0) return true; + + // Check if at least one endpoint is enabled + // If endpoint is not in status map, assume enabled (optimistic fallback) + return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false); }, [endpointsLoading, endpointStatus, baseRegistry]); const toolRegistry: Partial = useMemo(() => { diff --git a/frontend/src/proprietary/auth/UseSession.tsx b/frontend/src/proprietary/auth/UseSession.tsx index 0f3436b9a..2cb2868ec 100644 --- a/frontend/src/proprietary/auth/UseSession.tsx +++ b/frontend/src/proprietary/auth/UseSession.tsx @@ -95,23 +95,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { console.debug('[Auth] Initializing auth...'); - // First check if login is enabled - const configResponse = await fetch('/api/v1/config/app-config'); - if (configResponse.ok) { - const config = await configResponse.json(); - - // If login is disabled, skip authentication entirely - if (config.enableLogin === false) { - console.debug('[Auth] Login disabled - skipping authentication'); - if (mounted) { - setSession(null); - setLoading(false); - } - return; - } - } - - // Login is enabled, proceed with normal auth check + // Skip config check entirely - let the app handle login state + // The config will be fetched by useAppConfig when needed const { data, error } = await springAuth.getSession(); if (!mounted) return; diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 8567eb654..73c883b56 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -1,3 +1,5 @@ +import { BASE_PATH } from '@app/constants/app'; + /** * Spring Auth Client * @@ -7,6 +9,37 @@ * - No email confirmation flow (auto-confirmed on registration) */ +const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path'; +const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes +const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`; + +function normalizeRedirectPath(target?: string): string { + if (!target || typeof target !== 'string') { + return DEFAULT_REDIRECT_PATH; + } + + try { + const parsed = new URL(target, window.location.origin); + const path = parsed.pathname || '/'; + const query = parsed.search || ''; + return `${path}${query}`; + } catch { + const trimmed = target.trim(); + if (!trimmed) { + return DEFAULT_REDIRECT_PATH; + } + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + } +} + +function persistRedirectPath(path: string): void { + try { + document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`; + } catch (error) { + console.warn('[SpringAuth] Failed to persist OAuth redirect path', error); + } +} + // Auth types export interface User { id: string; @@ -85,20 +118,44 @@ class SpringAuthClient { } // Verify with backend + console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me'); const response = await fetch('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${token}`, }, }); + console.debug('[SpringAuth] /me response status:', response.status); + const contentType = response.headers.get('content-type'); + console.debug('[SpringAuth] /me content-type:', contentType); + if (!response.ok) { + // Log the error response for debugging + const errorBody = await response.text(); + console.error('[SpringAuth] getSession: /api/v1/auth/me failed', { + status: response.status, + statusText: response.statusText, + body: errorBody + }); + // Token invalid or expired - clear it localStorage.removeItem('stirling_jwt'); - console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')'); - return { data: { session: null }, error: null }; + console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage'); + return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } }; + } + + // Check if response is JSON before parsing + if (!contentType?.includes('application/json')) { + const text = await response.text(); + console.error('[SpringAuth] /me returned non-JSON:', { + contentType, + bodyPreview: text.substring(0, 200) + }); + throw new Error(`/api/v1/auth/me returned HTML instead of JSON`); } const data = await response.json(); + console.debug('[SpringAuth] /me response data:', data); // Create session object const session: Session = { @@ -151,6 +208,9 @@ class SpringAuthClient { localStorage.setItem('stirling_jwt', token); console.log('[SpringAuth] JWT stored in localStorage'); + // Dispatch custom event for other components to react to JWT availability + window.dispatchEvent(new CustomEvent('jwt-available')); + const session: Session = { user: data.user, access_token: token, @@ -220,6 +280,9 @@ class SpringAuthClient { options?: { redirectTo?: string; queryParams?: Record }; }): Promise<{ error: AuthError | null }> { try { + const redirectPath = normalizeRedirectPath(params.options?.redirectTo); + persistRedirectPath(redirectPath); + // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) const redirectUrl = `/oauth2/authorization/${params.provider}`; console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); @@ -299,6 +362,9 @@ class SpringAuthClient { // Store new token localStorage.setItem('stirling_jwt', newToken); + // Dispatch custom event for other components to react to JWT availability + window.dispatchEvent(new CustomEvent('jwt-available')); + // Get updated user info const userResponse = await fetch('/api/v1/auth/me', { headers: { diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 9c7a11ba7..0c7128368 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -36,6 +36,9 @@ export default function AuthCallback() { localStorage.setItem('stirling_jwt', token); console.log('[AuthCallback] JWT stored in localStorage'); + // Dispatch custom event for other components to react to JWT availability + window.dispatchEvent(new CustomEvent('jwt-available')) + // Refresh session to load user info into state await refreshSession(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9e3bb0f2a..2117b7bd1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -30,21 +30,24 @@ export default defineConfig(({ mode }) => { // tell vite to ignore watching `src-tauri` ignored: ['**/src-tauri/**'], }, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - }, - '/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - }, - '/login/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/login/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, }, }, },