From c93feb5dfc3d5cefa83391ff2e4df7d11ad8e0fa Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 26 Aug 2026 14:09:55 +0000 Subject: [PATCH 01/17] Remove a bunch of unnecessary casts from the frontend (#7662) # Description of Changes Originally, I wanted to re-enable typed linting on our repo but using Oxlint this time to avoid the memory and speed issues that ESLint was causing. Unfortunately, it's not stable enough yet to actually use on our repo (although it is close, I suspect it'll be stable enough fairly soon). I was able to remove many of the unnecessary casts that it found though, so even though this won't be enforced, it's still worth cleaning up what I've found. --- .../config/configSections/TeamSection.tsx | 8 +++--- .../core/components/fileEditor/FileEditor.tsx | 7 ++--- .../components/filesPage/FileDetailsPanel.tsx | 2 +- .../core/components/filesPage/FileGrid.tsx | 12 ++++----- .../components/filesPage/FileManagerView.tsx | 8 +++--- .../components/filesPage/VersionTimeline.tsx | 6 ++--- .../components/filesPage/folderTreeWidth.ts | 2 +- .../mobileSign/MobileDrawCanvas.tsx | 4 +-- .../core/components/onboarding/Onboarding.tsx | 3 +-- .../pageEditor/commands/pageCommands.ts | 2 +- .../shared/superSearch/SuperSearch.test.tsx | 4 +-- .../wetSignature/SignatureTypeSelector.tsx | 2 +- .../core/components/toast/ToastContext.tsx | 9 +++---- .../components/tools/FullscreenToolList.tsx | 10 +++---- .../core/components/tools/RightSidebar.tsx | 4 +-- .../core/components/tools/ToolRenderer.tsx | 4 +-- .../addPageNumbers/PageNumberPreview.tsx | 7 +---- .../addWatermark/WatermarkStyleSettings.tsx | 16 +++-------- .../AdjustPageScaleSettings.tsx | 4 +-- .../tools/certSign/WetSignatureInput.tsx | 4 +-- .../compare/ComparePixelWorkbenchView.tsx | 2 +- .../tools/compare/CompareWorkbenchView.tsx | 7 +++-- .../core/components/tools/compare/compare.ts | 2 +- .../hooks/useCompareChangeNavigation.ts | 6 ++--- .../tools/compare/hooks/useComparePanZoom.ts | 10 +++---- .../hooks/useCompareWorkbenchBarButtons.tsx | 3 +-- .../fullscreen/DetailedToolItem.stories.tsx | 4 +-- .../tools/overlayPdfs/OverlayPdfsSettings.tsx | 2 +- .../pdfTextEditor/PdfTextEditorSidebar.tsx | 4 +-- .../tools/shared/ReviewToolStep.tsx | 9 +++---- .../src/core/components/tools/showJS/utils.ts | 4 +-- .../tools/sign/MobileSignatureModal.test.tsx | 8 +++--- .../components/tools/sign/SignSettings.tsx | 6 ++--- .../tools/toolPicker/ToolButton.tsx | 4 +-- .../ValidateSignatureResults.tsx | 2 +- .../file/classificationToolRace.test.ts | 7 ++--- .../core/data/usePrototypeToolRegistry.tsx | 2 +- .../useAddPasswordOperation.test.ts | 2 +- .../autoRotate/useAutoRotateOperation.ts | 3 +-- .../tools/automate/useAutomateOperation.ts | 2 +- .../hooks/tools/compare/operationUtils.ts | 6 ++--- .../tools/compare/useCompareOperation.ts | 7 +++-- .../tools/convert/useConvertOperation.ts | 17 +++--------- .../core/hooks/tools/shared/toolAutomation.ts | 2 +- .../tools/shared/toolOperationDescriptor.ts | 9 +++---- .../hooks/tools/shared/toolOperationTypes.ts | 4 +-- .../hooks/tools/shared/useToolOperation.ts | 10 ++----- .../core/hooks/tools/useFavoriteToolItems.ts | 4 +-- .../utils/signatureStatus.test.ts | 5 +--- frontend/editor/src/core/i18n.ts | 2 +- frontend/editor/src/core/setupTests.ts | 2 +- .../src/core/tests/stubbed/settings.spec.ts | 8 ++---- frontend/editor/src/core/tools/Compare.tsx | 8 +++--- frontend/editor/src/core/tools/SharedSign.tsx | 6 ++--- .../core/tools/annotate/AnnotationPanel.tsx | 18 ++++++------- .../core/tools/formFill/FormFieldOverlay.tsx | 2 +- .../core/tools/formFill/FormFillContext.tsx | 2 +- .../formFill/providers/PdfiumFormProvider.ts | 20 +++++--------- frontend/editor/src/core/ui/Checkbox.tsx | 4 +-- .../utils/bulkselection/parseSelection.ts | 4 +-- .../src/desktop/hooks/useEndpointConfig.ts | 9 ++++--- .../editor/src/portal/auth/saasSupabase.ts | 2 +- .../docs/EndpointReferenceSection.tsx | 4 +-- .../pipelines/PipelineStepSettings.test.tsx | 3 +-- .../components/policies/PolicySetupWizard.tsx | 2 +- .../processor-flow/useFlowParticles.ts | 5 +--- .../src/portal/mocks/handlers/policies.ts | 2 +- .../portal/mocks/handlers/procurementSaas.ts | 2 +- .../editor/src/portal/queries/adapters.ts | 2 +- frontend/editor/src/portal/setupTests.ts | 2 +- .../proprietary/auth/springAuthClient.test.ts | 16 +++++------ .../proprietary/auth/supabase/UseSession.tsx | 2 +- .../policies/policyRunSettles.test.ts | 27 +++++++++---------- .../policies/useClientSideClassification.ts | 6 ++--- .../components/policies/usePolicyAutoRun.ts | 4 +-- .../shared/FileSidebarGroupControls.tsx | 2 +- .../components/shared/InviteMembersModal.tsx | 2 +- .../components/shared/UpgradeBanner.tsx | 10 ++----- .../config/LoginLandingSetting.test.tsx | 2 +- .../AdminConnectionsSection.tsx | 6 ++--- .../configSections/AdminUsageSection.tsx | 6 ++--- .../configSections/audit/AuditEventsTable.tsx | 8 +++--- .../configSections/plan/LicenseKeySection.tsx | 2 +- .../usage/UsageAnalyticsTable.tsx | 8 +++--- .../components/shared/fileSidebarGrouping.tsx | 3 +-- .../shared/fileSidebarGroupingLogic.ts | 2 +- .../proprietary/hooks/useFolderRunStatuses.ts | 2 +- .../editor/src/proprietary/policies/codec.ts | 6 ++--- .../src/proprietary/routes/Login.test.tsx | 3 +-- .../services/heuristic/heuristicEngine.ts | 3 +-- .../services/heuristic/heuristicExtractor.ts | 3 +-- .../src/proprietary/services/policyExport.ts | 2 +- .../editor/src/saas/auth/UseSession.test.ts | 2 +- .../components/SignupRequiredBootstrap.tsx | 8 ++---- .../saas/components/shared/AppConfigModal.tsx | 18 +++++-------- .../shared/charts/StackedBarChart.tsx | 6 ++--- .../shared/config/configSections/Overview.tsx | 10 +------ .../components/tools/sign/SignSettings.tsx | 6 ++--- .../editor/src/saas/routes/OAuthConsent.tsx | 5 ++-- frontend/editor/src/saas/setupTests.ts | 2 +- 100 files changed, 217 insertions(+), 345 deletions(-) diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx index 4eb064d8bf..ab8a184828 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx @@ -360,11 +360,9 @@ const TeamSection: React.FC = () => { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } + style={{ + "--table-border-color": "var(--mantine-color-gray-3)", + }} > { const index = stubsRef.current.findIndex((r) => r.id === fileId); if (index !== -1) { - setActiveFileId(fileId as string); + setActiveFileId(fileId); setActiveFileIndex(index); navActions.setWorkbench("viewer"); } @@ -410,10 +410,7 @@ const FileEditor = ({ onUnzipFile={handleUnzipFile} toolMode={toolMode} isSupported={isFileSupported(record.name)} - policies={ - policyFileBadges.get(record.id as string) ?? - EMPTY_POLICIES - } + policies={policyFileBadges.get(record.id) ?? EMPTY_POLICIES} /> ); })} diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx index 693d8efae7..ff570bbe1d 100644 --- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -140,7 +140,7 @@ export function FileDetailsPanel({ return null; } - const single = files.length === 1 ? files[0]! : null; + const single = files.length === 1 ? files[0] : null; const totalSize = files.reduce((sum, f) => sum + f.size, 0); const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : ""; // Files still needing a server upload; drives Save-to-server visibility. diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 40e17dc502..334ad76805 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -422,7 +422,7 @@ function GridView(props: FileGridProps) { parentPath={entry.parentPath} isSelected={selectedFileIds.has(entry.file.id)} isInWorkspace={ - activeWorkspaceFileIds?.has(entry.file.id as string) ?? false + activeWorkspaceFileIds?.has(entry.file.id) ?? false } selectedFileIds={selectedFileIds} multiSelectActive={selectedFileIds.size >= 2} @@ -938,7 +938,7 @@ function FileCard({ shiftKey: false, ctrlKey: true, metaKey: true, - } as unknown as React.MouseEvent); + }); }} onChange={() => { /* handled by onClick */ @@ -982,7 +982,7 @@ function FileCard({ · {fileDate} - +
@@ -1137,7 +1137,7 @@ function ListView( parentPath={entry.parentPath} isSelected={selectedFileIds.has(entry.file.id)} isInWorkspace={ - activeWorkspaceFileIds?.has(entry.file.id as string) ?? false + activeWorkspaceFileIds?.has(entry.file.id) ?? false } selectedFileIds={selectedFileIds} multiSelectActive={selectedFileIds.size >= 2} @@ -1424,7 +1424,7 @@ function FileRow({ shiftKey: false, ctrlKey: true, metaKey: true, - } as unknown as React.MouseEvent); + }); }} onChange={() => { /* handled by onClick */ @@ -1491,7 +1491,7 @@ function FileRow({ )} - + {isInWorkspace && ( diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index 36ffb9c4dc..4665784e79 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -474,7 +474,7 @@ export default function FileManagerView() { if (idx >= 0 && lastIdx >= 0) { const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx]; for (let i = a; i <= b; i += 1) { - next.add(visibleFiles[i]!.id); + next.add(visibleFiles[i].id); } return next; } @@ -593,7 +593,7 @@ export default function FileManagerView() { }); // Branch on requested stubs so already-active files still activate. if (materialized.length === 1) { - setActiveFileId(materialized[0]!.id); + setActiveFileId(materialized[0].id); navActions.setWorkbench("viewer"); } else if (materialized.length > 1) { navActions.setWorkbench("fileEditor"); @@ -1172,7 +1172,7 @@ export default function FileManagerView() { else if (e.key === "End") next = TAB_DEFS.length - 1; else return; e.preventDefault(); - const target = TAB_DEFS[next]!; + const target = TAB_DEFS[next]; setCurrentTab(target.id); focusTab(target.id); }} @@ -1602,7 +1602,7 @@ export default function FileManagerView() { ) ) return; - setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]); + setViewMode(v); }} aria-label={t("filesPage.viewMode.label", "View mode")} options={[ diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 05c96685b2..f484f936ef 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -120,14 +120,14 @@ export function VersionTimeline({ }; const rows: Row[] = useMemo(() => { if (!collapsible || showAllCollapsed) { - return ordered.map((v) => ({ kind: "version", version: v }) as Row); + return ordered.map((v) => ({ kind: "version", version: v })); } const head = ordered .slice(0, 3) - .map((v) => ({ kind: "version", version: v }) as Row); + .map((v) => ({ kind: "version", version: v })); const tail = ordered .slice(-2) - .map((v) => ({ kind: "version", version: v }) as Row); + .map((v) => ({ kind: "version", version: v })); const hidden = ordered.length - 5; return [...head, { kind: "ellipsis", hidden }, ...tail]; }, [collapsible, showAllCollapsed, ordered]); diff --git a/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts b/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts index 4aaf8099ec..a88b0e7f11 100644 --- a/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts +++ b/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts @@ -27,7 +27,7 @@ function depthOf( let cursor: FolderRecord | undefined = folder; while (cursor && cursor.parentFolderId) { depth += 1; - cursor = byId.get(cursor.parentFolderId as string); + cursor = byId.get(cursor.parentFolderId); if (depth > 50) break; } return depth; diff --git a/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx b/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx index 38c2be8e6c..8461727240 100644 --- a/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx +++ b/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx @@ -139,9 +139,9 @@ export const MobileDrawCanvas = forwardRef< // where the per-frame synthetic event alone would drop curvature. const events = "getCoalescedEvents" in e.nativeEvent - ? (e.nativeEvent as PointerEvent).getCoalescedEvents() + ? e.nativeEvent.getCoalescedEvents() : [e.nativeEvent as PointerEvent]; - const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect(); + const rect = e.currentTarget.getBoundingClientRect(); for (const ev of events) { stroke.points.push({ x: ev.clientX - rect.left, diff --git a/frontend/editor/src/core/components/onboarding/Onboarding.tsx b/frontend/editor/src/core/components/onboarding/Onboarding.tsx index e95aaf57aa..3ce2e2701f 100644 --- a/frontend/editor/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/editor/src/core/components/onboarding/Onboarding.tsx @@ -20,7 +20,6 @@ import { import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload"; import { SLIDE_DEFINITIONS, - type SlideId, type ButtonAction, } from "@app/components/onboarding/onboardingFlowConfig"; import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; @@ -322,7 +321,7 @@ export default function Onboarding() { ) { return null; } - return SLIDE_DEFINITIONS[currentStep.slideId as SlideId]; + return SLIDE_DEFINITIONS[currentStep.slideId]; }, [currentStep]); const currentSlideContent = useMemo(() => { diff --git a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts index db9385fc37..2ce10b929c 100644 --- a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts @@ -244,7 +244,7 @@ export class ReorderPagesCommand extends DOMCommand { .map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum), ) - .filter((page) => page !== undefined) as PDFPage[]; + .filter((page) => page !== undefined); const remainingPages = currentDoc.pages.filter( (page) => !this.selectedPages!.includes(page.pageNumber), diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx index f77984f090..931c87197f 100644 --- a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx +++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx @@ -84,7 +84,7 @@ function renderSearch( , @@ -103,7 +103,7 @@ describe("SuperSearch", () => { width: 320, height: 40, toJSON: () => "", - } as DOMRect); + }); Object.defineProperty(Element.prototype, "scrollIntoView", { value: vi.fn(), diff --git a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx index 7b4950f4aa..5cc14fcf3c 100644 --- a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx +++ b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx @@ -19,7 +19,7 @@ export const SignatureTypeSelector: React.FC = ({ return ( onChange(val as SignatureType)} + onChange={(val) => onChange(val)} options={[ { value: "draw", diff --git a/frontend/editor/src/core/components/toast/ToastContext.tsx b/frontend/editor/src/core/components/toast/ToastContext.tsx index a300dc1791..4fcc6d8b10 100644 --- a/frontend/editor/src/core/components/toast/ToastContext.tsx +++ b/frontend/editor/src/core/components/toast/ToastContext.tsx @@ -93,7 +93,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ? true : false, createdAt: Date.now(), - } as ToastInstance; + }; setToasts((prev) => { // Coalesce duplicates by alertType + title + body text if no explicit id was provided if (!options.id) { @@ -138,7 +138,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ...t, ...updates, progress, - } as ToastInstance; + }; // Detect completion but do not auto-flip to success. // Callers (e.g., compare workbench) explicitly set alertType when done. @@ -197,9 +197,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ), ); }; - window.addEventListener("toast:toggle", handler as EventListener); - return () => - window.removeEventListener("toast:toggle", handler as EventListener); + window.addEventListener("toast:toggle", handler); + return () => window.removeEventListener("toast:toggle", handler); }, []); return ( diff --git a/frontend/editor/src/core/components/tools/FullscreenToolList.tsx b/frontend/editor/src/core/components/tools/FullscreenToolList.tsx index 5296e21299..85a306c476 100644 --- a/frontend/editor/src/core/components/tools/FullscreenToolList.tsx +++ b/frontend/editor/src/core/components/tools/FullscreenToolList.tsx @@ -108,7 +108,7 @@ const FullscreenToolList = ({ window.open(tool.link, "_blank", "noopener,noreferrer"); return; } - onSelect(id as ToolId); + onSelect(id); }; if (showDescriptions) { @@ -274,15 +274,11 @@ const FullscreenToolList = ({ {showDescriptions ? (
- {tools.map(({ id, tool }) => - renderToolItem(id as ToolId, tool), - )} + {tools.map(({ id, tool }) => renderToolItem(id, tool))}
) : (
- {tools.map(({ id, tool }) => - renderToolItem(id as ToolId, tool), - )} + {tools.map(({ id, tool }) => renderToolItem(id, tool))}
)} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index a4bdbced7e..d66d30c442 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -108,7 +108,7 @@ export default function RightSidebar() { const activeTool: ToolRegistryEntry | null = inToolView && selectedToolKey - ? (toolRegistry[selectedToolKey as ToolId] ?? null) + ? (toolRegistry[selectedToolKey] ?? null) : null; const expandedWidth = "18.5rem"; @@ -131,7 +131,7 @@ export default function RightSidebar() { const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = []; collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((entry) => - items.push({ id: entry.id as ToolId, tool: entry.tool }), + items.push({ id: entry.id, tool: entry.tool }), ), ); return items; diff --git a/frontend/editor/src/core/components/tools/ToolRenderer.tsx b/frontend/editor/src/core/components/tools/ToolRenderer.tsx index 1429231ab7..4c99afbca2 100644 --- a/frontend/editor/src/core/components/tools/ToolRenderer.tsx +++ b/frontend/editor/src/core/components/tools/ToolRenderer.tsx @@ -19,9 +19,7 @@ const ToolRenderer = ({ // Get the tool from context (instead of direct hook call) const { toolRegistry } = useToolWorkflow(); const selectedTool = - selectedToolKey in toolRegistry - ? toolRegistry[selectedToolKey as ToolId] - : undefined; + selectedToolKey in toolRegistry ? toolRegistry[selectedToolKey] : undefined; // Handle tools that only work in workbenches (read, multiTool) if (selectedTool && !selectedTool.component && selectedTool.workbench) { diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx index 6e35b286a9..9991c4b849 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -261,12 +261,7 @@ export default function PageNumberPreview({ variant="tertiary" key={idx} className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ""} ${hoverTile === idx ? styles.gridTileHovered : ""}`} - onClick={() => - onParameterChange( - "position", - idx as AddPageNumbersParameters["position"], - ) - } + onClick={() => onParameterChange("position", idx)} onMouseEnter={() => setHoverTile(idx)} onMouseLeave={() => setHoverTile(null)} style={{ diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx index 1d9dad2995..c9ce1ca1bd 100644 --- a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx @@ -36,9 +36,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "rotation", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 0, + typeof value === "number" ? value : parseInt(value, 10) || 0, ) } min={-360} @@ -55,9 +53,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "opacity", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 50, + typeof value === "number" ? value : parseInt(value, 10) || 50, ) } min={0} @@ -77,9 +73,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "widthSpacer", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 50, + typeof value === "number" ? value : parseInt(value, 10) || 50, ) } min={0} @@ -96,9 +90,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "heightSpacer", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 50, + typeof value === "number" ? value : parseInt(value, 10) || 50, ) } min={0} diff --git a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx index aeb874b58e..e2bf0a8466 100644 --- a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx +++ b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx @@ -103,9 +103,7 @@ const AdjustPageScaleSettings = ({ - onParameterChange("orientation", value as Orientation) - } + onChange={(value) => onParameterChange("orientation", value)} options={orientationOptions} fullWidth /> diff --git a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx index f87fda19fe..af1913baa7 100644 --- a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx +++ b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx @@ -238,9 +238,7 @@ const WetSignatureInput = ({ - handleSignatureTypeChange(value as SignatureType) - } + onChange={(value) => handleSignatureTypeChange(value)} options={[ { label: t("sign.type.canvas", "Draw"), value: "canvas", disabled }, { label: t("sign.type.image", "Upload"), value: "image", disabled }, diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx index d45ccd75a0..35b3a7e0f4 100644 --- a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx @@ -170,7 +170,7 @@ const ComparePixelWorkbenchView = ({ setViewMode(value as PixelViewMode)} + onChange={(value) => setViewMode(value)} options={[ { value: "side-by-side", diff --git a/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx index 1df0f1c14a..5470e3ac8c 100644 --- a/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -28,7 +28,6 @@ import { updateToastProgress, dismissToast, } from "@app/components/toast"; -import type { ToastLocation } from "@app/components/toast/types"; interface CompareWorkbenchViewProps { data: CompareWorkbenchData | null; @@ -323,7 +322,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", ), body: `${countsText} ${t("compare.rendering.pagesRendered", "pages rendered")}`, - location: "bottom-right" as ToastLocation, + location: "bottom-right", isPersistentPopup: true, durationMs: 0, expandable: false, @@ -337,7 +336,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", ), body: `${countsText} ${t("compare.rendering.pagesRendered", "pages rendered")}`, - location: "bottom-right" as ToastLocation, + location: "bottom-right", isPersistentPopup: true, alertType: "neutral", // ensure it stays neutral until completion }); @@ -452,7 +451,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { "compare.rendering.pageNotReadyBody", "Some pages are still rendering. Navigation will snap once they are ready.", ), - location: "bottom-right" as ToastLocation, + location: "bottom-right", isPersistentPopup: false, durationMs: 2500, }); diff --git a/frontend/editor/src/core/components/tools/compare/compare.ts b/frontend/editor/src/core/components/tools/compare/compare.ts index 30d0c42304..b157745307 100644 --- a/frontend/editor/src/core/components/tools/compare/compare.ts +++ b/frontend/editor/src/core/components/tools/compare/compare.ts @@ -186,7 +186,7 @@ export const getFileFromSelection = ( ): StirlingFile | null => { if (explicit) return explicit; if (!fileId) return null; - return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null; + return selectors.getFile(fileId) ?? null; }; export const getStubFromSelection = ( diff --git a/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts b/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts index d7d8e95635..e56187f5f2 100644 --- a/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts +++ b/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts @@ -79,7 +79,7 @@ export const useCompareChangeNavigation = ( const inner = anchor.closest( ".compare-diff-page__inner", ) as HTMLElement | null; - const topPercent = parseFloat((anchor as HTMLElement).style.top || "0"); + const topPercent = parseFloat(anchor.style.top || "0"); if (pageEl && inner && !Number.isNaN(topPercent)) { const innerRect = inner.getBoundingClientRect(); const innerHeight = Math.max(1, innerRect.height); @@ -156,9 +156,7 @@ export const useCompareChangeNavigation = ( ".compare-diff-page", ) as HTMLElement | null; const pageNumAttr = pageEl?.getAttribute("data-page-number"); - const topPercent = parseFloat( - (anchor as HTMLElement).style.top || "0", - ); + const topPercent = parseFloat(anchor.style.top || "0"); if (pageNumAttr) { const peerPageEl = peer.querySelector( `.compare-diff-page[data-page-number="${pageNumAttr}"]`, diff --git a/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts index bac47de952..d44c55e1ec 100644 --- a/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts +++ b/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts @@ -323,7 +323,7 @@ export const useComparePanZoom = ({ const pages = getPagesForPane(pane); const rotation = pages[0]?.rotation ?? 0; const normalized = ((rotation % 360) + 360) % 360; - return normalized as 0 | 90 | 180 | 270 | number; + return normalized; }, [getPagesForPane], ); @@ -656,7 +656,7 @@ export const useComparePanZoom = ({ }; edgeOverscrollRef.current[pane] = 0; lastActivePaneRef.current = pane; - (container as HTMLDivElement).style.cursor = "grabbing"; + container.style.cursor = "grabbing"; }, [isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan], ); @@ -700,11 +700,7 @@ export const useComparePanZoom = ({ : comparisonScrollRef.current; if (sourceEl) { const zoom = drag.source === "base" ? baseZoom : comparisonZoom; - (sourceEl as HTMLDivElement).style.cursor = isPanMode - ? zoom > 1 - ? "grab" - : "auto" - : ""; + sourceEl.style.cursor = isPanMode ? (zoom > 1 ? "grab" : "auto") : ""; } panDragRef.current.active = false; panDragRef.current.source = null; diff --git a/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx index 3ddbbbf3da..076ac877d5 100644 --- a/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx @@ -3,7 +3,6 @@ import type React from "react"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert } from "@app/components/toast"; -import type { ToastLocation } from "@app/components/toast/types"; import type { WorkbenchBarButtonWithAction } from "@app/hooks/useWorkbenchBarButtons"; import { useIsMobile } from "@app/hooks/useIsMobile"; @@ -179,7 +178,7 @@ export const useCompareWorkbenchBarButtons = ({ "Tip: Arrow Up/Down scroll both panes when unlinked is off.", ), durationMs: 5000, - location: "bottom-center" as ToastLocation, + location: "bottom-center", expandable: false, }); } diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx index cd443e248a..901824aaf6 100644 --- a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx @@ -94,10 +94,10 @@ type Story = StoryObj; /** An available tool rendered in its default, unselected state. */ export const Default: Story = { - render: () => , + render: () => , }; /** The active tool in the panel — highlighted selected state. */ export const Selected: Story = { - render: () => , + render: () => , }; diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx index e7246bb4e3..8df455182e 100644 --- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx +++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -137,7 +137,7 @@ export default function OverlayPdfsSettings({ - onParameterChange("overlayPosition", (v === "1" ? 1 : 0) as 0 | 1) + onParameterChange("overlayPosition", v === "1" ? 1 : 0) } options={[ { diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx index 1892aaa7f3..a0ce9fc362 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx @@ -279,9 +279,7 @@ const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => { - handleModeChangeRequest(value as GroupingMode) - } + onChange={(value) => handleModeChangeRequest(value)} options={[ { label: t("pdfTextEditor.groupingMode.auto", "Auto"), diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index 7918bac7ca..ef596c2a0d 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -12,7 +12,6 @@ import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; import { saveOperationResults } from "@app/services/operationResultsSaveService"; import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; -import { FileId } from "@app/types/fileContext"; import i18n from "@app/i18n"; export interface ReviewToolStepProps { @@ -65,11 +64,11 @@ function ReviewStepContent({ downloadFilename: operation.downloadFilename || "download", downloadLocalPath: operation.downloadLocalPath, outputFileIds: operation.outputFileIds, - getFile: (fileId) => selectors.getFile(fileId as FileId), - getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId), + getFile: (fileId) => selectors.getFile(fileId), + getStub: (fileId) => selectors.getStirlingFileStub(fileId), markSaved: (fileId, savedPath) => { - const stub = selectors.getStirlingFileStub(fileId as FileId); - fileActions.updateStirlingFileStub(fileId as FileId, { + const stub = selectors.getStirlingFileStub(fileId); + fileActions.updateStirlingFileStub(fileId, { localFilePath: stub?.localFilePath ?? savedPath, isDirty: false, }); diff --git a/frontend/editor/src/core/components/tools/showJS/utils.ts b/frontend/editor/src/core/components/tools/showJS/utils.ts index e946ec271e..7c6530c21b 100644 --- a/frontend/editor/src/core/components/tools/showJS/utils.ts +++ b/frontend/editor/src/core/components/tools/showJS/utils.ts @@ -186,7 +186,7 @@ export function tokenizeToLines( } if (isStringDelimiter) { - startString(ch as '"' | "'" | "`"); + startString(ch); continue; } @@ -312,7 +312,7 @@ export function computeBlocks( continue; } if (isStringDelimiter) { - startString(ch as '"' | "'" | "`"); + startString(ch); continue; } if (isOpenBrace) { diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx index c8607d55c4..4f68e7c68c 100644 --- a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx @@ -52,9 +52,9 @@ function primeSession( mockedApi.post.mockResolvedValue({ status: 200, data: SESSION_INFO, - } as never); - mockedApi.delete.mockResolvedValue({ status: 200 } as never); - mockedApi.get.mockImplementation(((url: string, config?: unknown) => { + }); + mockedApi.delete.mockResolvedValue({ status: 200 }); + mockedApi.get.mockImplementation((url: string, config?: unknown) => { if (url.includes("/files/")) { return Promise.resolve({ status: 200, data: { files } } as never); } @@ -70,7 +70,7 @@ function primeSession( } as never); } return Promise.reject(new Error(`unexpected GET ${url}`)); - }) as never); + }); } function renderModal( diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index 4aa9f8ffd3..1d78343c79 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -519,7 +519,7 @@ const SignSettings = ({ return; } const nextSource = allowedSignatureSources.includes( - parameters.signatureType as SignatureSource, + parameters.signatureType, ) ? (parameters.signatureType as SignatureSource) : effectiveDefaultSource; @@ -1282,9 +1282,7 @@ const SignSettings = ({ - handleSignatureSourceChange(value as SignatureSource) - } + onChange={(value) => handleSignatureSourceChange(value)} options={sourceOptions} /> )} diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 4792fd721a..8daf2cb019 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -74,7 +74,7 @@ const ToolButton: React.FC = ({ const { hotkeys } = useHotkeys(); const binding = hotkeys[id]; const { getToolNavigation } = useToolNavigation(); - const fav = isFavorite(id as ToolId); + const fav = isFavorite(id); // Check if this tool will route to SaaS backend (desktop only) const rawEndpoint = tool.operationConfig?.endpoint; @@ -308,7 +308,7 @@ const ToolButton: React.FC = ({ hasStars && !visuallyUnavailable ? ( toggleFavorite(id as ToolId)} + onToggle={() => toggleFavorite(id)} className="tool-button-star" size="xs" /> diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx index 22020ac6dc..23a9d9e949 100644 --- a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx @@ -294,7 +294,7 @@ const ValidateSignatureResults = ({ setSelectedType(v as "pdf" | "csv" | "json")} + onChange={(v) => setSelectedType(v)} options={downloadTypeOptions} />
{v.endpoints.map((e) => (
- + {e.endpoint} {e.name} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx index 0b15b7233a..0850499a99 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx @@ -275,8 +275,7 @@ describe("PipelineStepSettings: every tool's settings render in the portal", () const Settings = entry.automationSettings as ComponentType< ToolAutomationSettingsProps >; - const params = (entry.operationConfig?.defaultParameters ?? - {}) as ErasedToolParams; + const params = entry.operationConfig?.defaultParameters ?? {}; const caught: { error: Error | null } = { error: null }; // The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index b50e2efe12..cccdb36e40 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -457,7 +457,7 @@ function PolicySetupWizardBody({ variant="underline" ariaLabel={t("portal.policies.wizard.tabs.ariaLabel")} activeKey={step} - onChange={(k) => setStep(k as Step)} + onChange={(k) => setStep(k)} items={[ { key: "workflow", label: t("portal.policies.wizard.tabs.workflow") }, { key: "settings", label: t("portal.policies.wizard.tabs.settings") }, diff --git a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts index 65fc6cc9ad..a32ea76a4d 100644 --- a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts +++ b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts @@ -134,10 +134,7 @@ export function useFlowParticles({ for (let i = 0; i < meanInterval.length; i++) { if (!Number.isFinite(meanInterval[i]) || !g.srcs[i]) continue; if (now >= nextEmit[i] && particles.length < MAX_PARTICLES) { - const c = document.createElementNS( - NS, - "circle", - ) as SVGCircleElement; + const c = document.createElementNS(NS, "circle"); c.setAttribute("r", "2.5"); c.setAttribute("opacity", "0.75"); c.style.fill = "var(--c-primary)"; diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts index 1bfc435d4c..ce6c041ae9 100644 --- a/frontend/editor/src/portal/mocks/handlers/policies.ts +++ b/frontend/editor/src/portal/mocks/handlers/policies.ts @@ -35,7 +35,7 @@ function nextId(categoryId: string): string { } function categoryId(wire: WirePolicy): string { - return (wire.output?.options?.categoryId as string | undefined) ?? ""; + return wire.output?.options?.categoryId ?? ""; } export const policiesHandlers = [ diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts index fe75827c2d..2f5246ce8d 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts @@ -256,7 +256,7 @@ export const procurementSaasHandlers = [ stage: "quote", licensed: true, latestQuote: quote, - } as never; + }; return HttpResponse.json(quote); }), http.post(`${SAAS}/api/v1/procurement/trial/extend`, () => { diff --git a/frontend/editor/src/portal/queries/adapters.ts b/frontend/editor/src/portal/queries/adapters.ts index 083e396bad..3cb091d8cd 100644 --- a/frontend/editor/src/portal/queries/adapters.ts +++ b/frontend/editor/src/portal/queries/adapters.ts @@ -11,6 +11,6 @@ export function toAsyncState(query: UseQueryResult): AsyncState { return { data: query.data ?? null, loading: query.isPending, - error: (query.error as Error | null) ?? null, + error: query.error ?? null, }; } diff --git a/frontend/editor/src/portal/setupTests.ts b/frontend/editor/src/portal/setupTests.ts index 7de4f9f309..22d95aa1ab 100644 --- a/frontend/editor/src/portal/setupTests.ts +++ b/frontend/editor/src/portal/setupTests.ts @@ -50,7 +50,7 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), -})) as unknown as typeof IntersectionObserver; +})); Object.defineProperty(window, "matchMedia", { writable: true, diff --git a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts index 54fed2028d..13f5c6d651 100644 --- a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts @@ -12,11 +12,7 @@ import apiClient from "@app/services/apiClient"; // + oauthNavigation seam, so springAuth routes through the mocks below. import "@app/auth/configureSpringAuth"; import { allowConsole, expectConsole } from "@app/tests/failOnConsole"; -import { - AxiosError, - type AxiosResponse, - type InternalAxiosRequestConfig, -} from "axios"; +import { AxiosError, type InternalAxiosRequestConfig } from "axios"; // Mock apiClient vi.mock("@app/services/apiClient"); @@ -59,7 +55,7 @@ describe("SpringAuthClient", () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ status: 200, data: { user: mockUser }, - } as unknown as AxiosResponse); + }); const result = await springAuth.getSession(); @@ -176,7 +172,7 @@ describe("SpringAuthClient", () => { expires_in: 3600, }, }, - } as unknown as AxiosResponse); + }); // Spy on window.dispatchEvent const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); @@ -235,7 +231,7 @@ describe("SpringAuthClient", () => { vi.mocked(apiClient.post).mockResolvedValueOnce({ status: 200, data: {}, - } as unknown as AxiosResponse); + }); const result = await springAuth.signOut(); @@ -288,7 +284,7 @@ describe("SpringAuthClient", () => { expires_in: 3600, }, }, - } as unknown as AxiosResponse); + }); const result = await springAuth.refreshSession(); @@ -375,7 +371,7 @@ describe("SpringAuthClient", () => { expect(isSafePostLoginRedirect("")).toBe(false); expect(isSafePostLoginRedirect(null)).toBe(false); expect(isSafePostLoginRedirect(undefined)).toBe(false); - expect(isSafePostLoginRedirect(42 as unknown)).toBe(false); + expect(isSafePostLoginRedirect(42)).toBe(false); }); it("rejects protocol-relative and absolute URLs (open-redirect guard)", () => { diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx index 6bcc6b501e..385f3f4e35 100644 --- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx @@ -46,7 +46,7 @@ function mapUser(user: SbUser): AuthUser { "", role: readRole(user), is_anonymous: user.is_anonymous, - app_metadata: user.app_metadata as Record, + app_metadata: user.app_metadata, }; } diff --git a/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts index fdce331821..c3c6be7137 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts @@ -7,20 +7,19 @@ import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; * effect used to skip those runs entirely, so `imported` never flipped and the * file's badge + blocking overlay spun forever - on every engine. */ -const run = (overrides: Partial = {}): PolicyRunRecord => - ({ - runId: "r", - categoryId: "security", - fileId: "f", - fileName: "f.pdf", - fileSize: 1, - target: "saas", - status: "COMPLETED", - outputs: [], - error: null, - startedAt: 0, - ...overrides, - }) as PolicyRunRecord; +const run = (overrides: Partial = {}): PolicyRunRecord => ({ + runId: "r", + categoryId: "security", + fileId: "f", + fileName: "f.pdf", + fileSize: 1, + target: "saas", + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + ...overrides, +}); describe("finishedWithNothingToDeliver", () => { it("settles a completed run that produced no output", () => { diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index 64fd849d87..ede1768b2a 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -99,7 +99,7 @@ export function useClientSideClassification(): void { if (claimed.current.has(key)) continue; claimed.current.add(key); const verdict = await classifyStub( - stub.id as FileId, + stub.id, stub.name, stub.size ?? 0, ); @@ -108,11 +108,11 @@ export function useClientSideClassification(): void { if (verdict == null) continue; // Deliver unconditionally - a re-render must never discard a computed // (and already metered) result. Writes are idempotent. - updateStirlingFileStub(stub.id as FileId, { + updateStirlingFileStub(stub.id, { classificationLabels: verdict.labels, classificationConfidence: verdict.confidence, }); - const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { + const ok = await fileStorage.updateFileMetadata(stub.id, { classificationLabels: verdict.labels, classificationConfidence: verdict.confidence, }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 936fef8119..0638b1432c 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -768,7 +768,7 @@ async function importOutputs( // Mark the outputs handled BEFORE adding them (belt-and-suspenders session // guard on top of derivedFromTool) so the auto-run never enforces the policy // on its own output — that would version endlessly in a loop. - for (const s of categorized) markHandled(s.id as string); + for (const s of categorized) markHandled(s.id); deliveredIds = categorized.map((s) => s.id as string); if (ctx.parentStub) { // Input is in the active workspace: version it in place, silently — the @@ -799,7 +799,7 @@ async function importOutputs( derivedFromTool: true, }); // Belt-and-suspenders session guard on top of derivedFromTool. - for (const f of added) markHandled(f.fileId as string); + for (const f of added) markHandled(f.fileId); deliveredIds = added.map((f) => f.fileId as string); // Mark each new-file output as tool-derived (the versioned path gets this from the // CONSUME_FILES reducer; the addFiles path doesn't). This is the real loop guard: the dispatch diff --git a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx index 41d1358594..1b37a13bb7 100644 --- a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx +++ b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx @@ -46,7 +46,7 @@ export function FileSidebarGroupControls({ const ids = new Set(); for (const key of category.labelKeys) { for (const stub of byLabel.get(key)?.stubs ?? []) { - ids.add(stub.id as string); + ids.add(stub.id); } } counts.set(category.id, ids.size); diff --git a/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx index e57f60395f..063fb1c4ef 100644 --- a/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx @@ -460,7 +460,7 @@ export default function InviteMembersModal({ { - setInviteMode(value as "email" | "direct" | "link"); + setInviteMode(value); setGeneratedInviteLink(null); }} options={[ diff --git a/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx index 365c64a030..fdb92dc271 100644 --- a/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx @@ -67,15 +67,9 @@ const UpgradeBanner: React.FC = () => { } }; - window.addEventListener( - UPGRADE_BANNER_TEST_EVENT, - handleTestEvent as EventListener, - ); + window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent); return () => { - window.removeEventListener( - UPGRADE_BANNER_TEST_EVENT, - handleTestEvent as EventListener, - ); + window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent); }; }, [isDev]); diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx index 31d45c0d45..b9caf6ba8d 100644 --- a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; const h = vi.hoisted(() => ({ - prefs: { loginLandingView: "processor" as "processor" | "editor" }, + prefs: { loginLandingView: "processor" }, update: vi.fn(), get: vi.fn(), })); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index f788551551..b993889e52 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -518,11 +518,11 @@ export default function AdminConnectionsSection() { updatedSettings: Record, ) => { if (provider.id === "smtp") { - setSettings({ ...settings, mail: updatedSettings as MailSettings }); + setSettings({ ...settings, mail: updatedSettings }); } else if (provider.id === "telegram") { setSettings({ ...settings, - telegram: updatedSettings as TelegramSettingsData, + telegram: updatedSettings, }); } else if (provider.id === "googledrive") { const gd = updatedSettings as GoogleDriveSettings; @@ -534,7 +534,7 @@ export default function AdminConnectionsSection() { googleDriveAppId: gd.appId, }); } else if (provider.id === "saml2") { - setSettings({ ...settings, saml2: updatedSettings as Saml2Settings }); + setSettings({ ...settings, saml2: updatedSettings }); } else if (provider.id === "oauth2-generic") { const generic = updatedSettings as OAuth2GenericSettings; setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } }); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx index 6b46789bdd..d0a0a1b2e9 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx @@ -332,9 +332,7 @@ const AdminUsageSection: React.FC = () => { - setDisplayMode(value as "top10" | "top20" | "all") - } + onChange={(value) => setDisplayMode(value)} options={[ { value: "top10", @@ -373,7 +371,7 @@ const AdminUsageSection: React.FC = () => { setDataType(value as "all" | "api" | "ui")} + onChange={(value) => setDataType(value)} options={[ { value: "all", diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx index feca3696d4..22263596c0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx @@ -257,11 +257,9 @@ const AuditEventsTable: React.FC = ({ verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } + style={{ + "--table-border-color": "var(--mantine-color-gray-3)", + }} > = ({ { - setInputMethod(value as "text" | "file"); + setInputMethod(value); // Clear opposite input when switching if (value === "text") setLicenseFile(null); if (value === "file") setLicenseKeyInput(""); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx index f27d5d09a3..a89cb477e3 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx @@ -32,11 +32,9 @@ const UsageAnalyticsTable: React.FC = ({ data }) => { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } + style={{ + "--table-border-color": "var(--mantine-color-gray-3)", + }} > diff --git a/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx b/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx index baf3d812cb..447bd19a68 100644 --- a/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx +++ b/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx @@ -20,7 +20,6 @@ import { } from "@app/services/fileSidebarCategories"; import { buildLabelGroups } from "@app/components/shared/fileSidebarGroupingLogic"; import { scheduleIdle } from "@app/utils/scheduleIdle"; -import type { FileId } from "@app/types/file"; import type { StirlingFileStub } from "@app/types/fileContext"; import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping"; @@ -81,7 +80,7 @@ export function useFileSidebarGroups( if (cancelled) return; attempted.current.add(attemptKey(stub)); if (labels) { - const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { + const ok = await fileStorage.updateFileMetadata(stub.id, { classificationLabels: labels, }); if (ok) wrote = true; diff --git a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts index 82569a9bd5..5b4b30ce33 100644 --- a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts +++ b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts @@ -70,7 +70,7 @@ export function buildLabelGroups( // Other = files in no visible group: unlabelled, or labelled only under hidden categories. const covered = new Set(); for (const group of visible) { - for (const stub of group.stubs) covered.add(stub.id as string); + for (const stub of group.stubs) covered.add(stub.id); } const other = stubs.filter((stub) => !covered.has(stub.id as string)); diff --git a/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts b/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts index 031d514f5f..78f00b1fab 100644 --- a/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts +++ b/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts @@ -51,7 +51,7 @@ export function useFolderRunStatuses( ); return [folder.id, deriveStatus(runs)] as const; } catch { - return [folder.id, "idle" as FolderRunStatus] as const; + return [folder.id, "idle"] as const; } }), ); diff --git a/frontend/editor/src/proprietary/policies/codec.ts b/frontend/editor/src/proprietary/policies/codec.ts index 2ef68514e4..e6fbdf739d 100644 --- a/frontend/editor/src/proprietary/policies/codec.ts +++ b/frontend/editor/src/proprietary/policies/codec.ts @@ -62,10 +62,8 @@ export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState { name: policy.name, enabled: policy.enabled, categoryId, - sources: Array.isArray(raw.sources) ? (raw.sources as string[]) : [], - scopeTypes: Array.isArray(raw.scopeTypes) - ? (raw.scopeTypes as string[]) - : [], + sources: Array.isArray(raw.sources) ? raw.sources : [], + scopeTypes: Array.isArray(raw.scopeTypes) ? raw.scopeTypes : [], reviewerEmail: str(raw.reviewerEmail), fieldValues: raw.fieldValues ?? {}, runOn: resolveRunOn(raw.runOn, categoryId), diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index d2cb80360f..29ae9d9d79 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -10,7 +10,6 @@ import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; import apiClient from "@app/services/apiClient"; import { configureSpringAuth } from "@app/auth/config"; -import type { AxiosInstance } from "axios"; // Mock i18n to return fallback text vi.mock("react-i18next", () => ({ @@ -137,7 +136,7 @@ describe("Login", () => { // The shared login hook reads getSpringAuthConfig().http; in the real app, // startup points that at apiClient. Mirror that here so the mocked apiClient // serves the login-ui-data fetch. - configureSpringAuth({ http: apiClient as unknown as AxiosInstance }); + configureSpringAuth({ http: apiClient }); }); it("should render login form", async () => { diff --git a/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts b/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts index ed01b9542e..8d2f795636 100644 --- a/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts +++ b/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts @@ -387,8 +387,7 @@ export async function ensureRulesLoaded(): Promise { if (!loadPromise) { loadPromise = import("@app/services/heuristic/heuristicRules.json").then( (mod) => { - const root = ((mod as { default?: RulesFile }).default ?? - (mod as RulesFile)) as RulesFile; + const root = (mod as { default?: RulesFile }).default ?? mod; PREPARED = prepare(root.labels ?? []); PRIORS = loadPriors(root.priors ?? {}); }, diff --git a/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts b/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts index 426c1133e1..fb51b407b6 100644 --- a/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts +++ b/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts @@ -186,8 +186,7 @@ async function metadata( } catch { return {}; } - const get = (k: string) => - typeof info[k] === "string" ? (info[k] as string) : ""; + const get = (k: string) => (typeof info[k] === "string" ? info[k] : ""); return { title: get("Title"), author: get("Author"), diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index 78b747182b..91b68f8edd 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -222,7 +222,7 @@ export async function enforceExportPolicies( fileId, fileName: file.name, fileSize: file.size, - target: versionRun!.target, + target: versionRun.target, status: "COMPLETED", outputs: versionRun.outputs, error: null, diff --git a/frontend/editor/src/saas/auth/UseSession.test.ts b/frontend/editor/src/saas/auth/UseSession.test.ts index d5f8124948..94e1ffcb6a 100644 --- a/frontend/editor/src/saas/auth/UseSession.test.ts +++ b/frontend/editor/src/saas/auth/UseSession.test.ts @@ -17,7 +17,7 @@ function makeUser(overrides: Partial = {}): User { user_metadata: {}, created_at: "2026-01-01T00:00:00Z", ...overrides, - } as User; + }; } describe("saas deriveDisplayName", () => { diff --git a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx index b9028ce22b..7ba4230ef5 100644 --- a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx +++ b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx @@ -46,12 +46,8 @@ export default function SignupRequiredBootstrap() { return true; }); }; - window.addEventListener("payg:signupRequired", handler as EventListener); - return () => - window.removeEventListener( - "payg:signupRequired", - handler as EventListener, - ); + window.addEventListener("payg:signupRequired", handler); + return () => window.removeEventListener("payg:signupRequired", handler); }, []); // Map the server's gate categories to user-facing nouns. The server diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index ab93f734fb..11b8957deb 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -80,12 +80,8 @@ const AppConfigModal: React.FC = ({ setMobilePane("content"); } }; - window.addEventListener("appConfig:navigate", handler as EventListener); - return () => - window.removeEventListener( - "appConfig:navigate", - handler as EventListener, - ); + window.addEventListener("appConfig:navigate", handler); + return () => window.removeEventListener("appConfig:navigate", handler); }, []); // When the modal opens via a /settings/
deep link (navigateToSettings — e.g. the @@ -122,9 +118,8 @@ const AppConfigModal: React.FC = ({ setNotice(detail.notice); } }; - window.addEventListener("appConfig:notice", handler as EventListener); - return () => - window.removeEventListener("appConfig:notice", handler as EventListener); + window.addEventListener("appConfig:notice", handler); + return () => window.removeEventListener("appConfig:notice", handler); }, []); // Full-screen overlays that live inside our React tree (e.g. the PAYG @@ -140,9 +135,8 @@ const AppConfigModal: React.FC = ({ | undefined; setOverlayActive(Boolean(detail?.open)); }; - window.addEventListener("appConfig:overlay", handler as EventListener); - return () => - window.removeEventListener("appConfig:overlay", handler as EventListener); + window.addEventListener("appConfig:overlay", handler); + return () => window.removeEventListener("appConfig:overlay", handler); }, []); const colors = useMemo( diff --git a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx index 5f56843dae..0224b0837b 100644 --- a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx +++ b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx @@ -211,11 +211,9 @@ export default function StackedBarChart({ setTooltipContent(html); const tooltip = tooltipRef.current; if (tooltip) tooltip.style.opacity = "1"; - positionTooltip(event as unknown as MouseEvent); + positionTooltip(event); }) - .on("mousemove", (event: MouseEvent) => - positionTooltip(event as unknown as MouseEvent), - ) + .on("mousemove", (event: MouseEvent) => positionTooltip(event)) .on("mouseleave", hideTooltip); // Animate reveal of used segments (only on first load, not on re-renders) diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx index 3a28152425..62f84977c1 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx @@ -562,15 +562,7 @@ const Overview: React.FC = ({ onLogoutClick }) => { style={{ width: 16, height: 16 }} /> } - onClick={() => - handleOAuthUpgrade( - provider.id as - | "github" - | "google" - | "apple" - | "azure", - ) - } + onClick={() => handleOAuthUpgrade(provider.id)} disabled={isLoading} > {provider.label} diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx index 503087cbe4..59920ab6fb 100644 --- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx @@ -547,7 +547,7 @@ const SignSettings = ({ return; } const nextSource = allowedSignatureSources.includes( - parameters.signatureType as SignatureSource, + parameters.signatureType, ) ? (parameters.signatureType as SignatureSource) : effectiveDefaultSource; @@ -1314,9 +1314,7 @@ const SignSettings = ({ - handleSignatureSourceChange(value as SignatureSource) - } + onChange={(value) => handleSignatureSourceChange(value)} options={sourceOptions} /> )} diff --git a/frontend/editor/src/saas/routes/OAuthConsent.tsx b/frontend/editor/src/saas/routes/OAuthConsent.tsx index f22f13fa19..f2acc03899 100644 --- a/frontend/editor/src/saas/routes/OAuthConsent.tsx +++ b/frontend/editor/src/saas/routes/OAuthConsent.tsx @@ -35,9 +35,8 @@ interface AuthorizationDetails { }; } -const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string; -const SUPABASE_KEY = import.meta.env - .VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY as string; +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +const SUPABASE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; async function gotrue( path: string, diff --git a/frontend/editor/src/saas/setupTests.ts b/frontend/editor/src/saas/setupTests.ts index 3e8864e49e..6b04e18efd 100644 --- a/frontend/editor/src/saas/setupTests.ts +++ b/frontend/editor/src/saas/setupTests.ts @@ -130,7 +130,7 @@ Object.defineProperty(globalThis, "crypto", { } return array; }), - } as unknown as Crypto, + }, writable: true, configurable: true, }); From caeca0b88ad2f1eaa1fc0aa722b7d796becc4d9d Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:34:13 +0000 Subject: [PATCH 02/17] Fix classification escalation: the local pass was claiming the server dispatch key (#7667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #7580: the escalation it added could never fire. ## What's broken The auto-run skips a policy that has already run on a file, keyed on `(categoryId, fileId)`. `recordRunStart` claims that key — and #7580 has the **browser-side first pass** record its own run under `categoryId: "classification"` for the uploaded file. So the local heuristic ticks the very key the server escalation checks, and the AI is never asked, at any confidence. Trigger is the default seeded setup: **Classification as the only on-upload policy**, and a local verdict below `high`. Any other on-upload policy masks it, because classification then targets that policy's output — a new file id whose key was never claimed. That's why this went unnoticed. Two smaller faults in the same path: - A chained output carried no `classificationConfidence`, so `shouldDispatchToAi` waited for a verdict that could never arrive (a tool-derived file gets no local pass). - Browser-local runs were polled against the server: 3 × 404 per file, after which `MAX_NOT_FOUND` marked a local run that had actually **succeeded** as `FAILED`. ## The fix - `PolicyRunRecord.browserLocal`; `recordRunStart` skips the dispatch claim for such a run. It is the first pass, not the policy's run. - The local pass meters under `classification:local-meter` instead of the category id, so metering dedupe survives without suppressing dispatch. - The poll effect skips browser-local runs. - `CONSUME_FILES` inherits `classificationConfidence` alongside the labels, so the verdict survives a version bump. ## How to test Download [`low-confidence-classification.pdf`](https://github.com/Stirling-Tools/Stirling-PDF/raw/fix/chained-classification-confidence/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf) (checked in as a fixture, verdict pinned by a test). With **Classification as the only on-upload policy**, upload it and watch the Network tab: - **Before:** no `POST /api/v1/policies/{id}/run` for classification, ever. Console shows `local-classification-*` 404s. - **After:** exactly one, and the engine receives `POST /api/v1/documents/classify`. Judge it on that request, not on the resulting label — the model's answer varies, so a label comparison can pass or fail for the wrong reason. Headless equivalent: ``` npx vitest run --project proprietary src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx ``` Passes here, fails on `main` on "asks the AI about an unsure verdict even though the local pass already ran". Its other two cases pass on both, so the guards still hold: a confident verdict still costs nothing, and a file with no verdict yet still waits rather than racing the free pass. New tests drive the **real** run store — mocking it is what let this through. `task frontend:check`: 255 files / 2202 tests. --- .../core/contexts/file/FileReducer.test.ts | 48 +++++ .../src/core/contexts/file/FileReducer.ts | 26 ++- .../policies/policyRunStore.test.ts | 23 +++ .../components/policies/policyRunStore.ts | 18 +- .../useClientSideClassification.test.tsx | 10 +- .../policies/useClientSideClassification.ts | 15 +- .../policies/usePolicyAutoRun.chain.test.tsx | 188 +++++++++++++++++- .../usePolicyAutoRun.escalation.test.tsx | 155 +++++++++++++++ .../components/policies/usePolicyAutoRun.ts | 9 +- .../data/classificationPolicy.test.ts | 23 +++ .../proprietary/data/classificationPolicy.ts | 10 +- .../low-confidence-classification.pdf | 95 +++++++++ .../fixtures/lowConfidenceFixture.test.ts | 60 ++++++ 13 files changed, 651 insertions(+), 29 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx create mode 100644 frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf create mode 100644 frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index bde6a17a02..cdba68b013 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -209,6 +209,54 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", ]); }); + it("carries classificationConfidence forward with the labels", () => { + // The confidence is part of the verdict: without it the escalation decision + // (shouldDispatchToAi) dies at the version boundary and a chained + // classification never runs. + const start = stateWith([ + stub("a", { + classificationLabels: ["Invoice"], + classificationConfidence: "low", + }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("a-v2")], + }, + }); + expect(next.files.byId["a-v2" as FileId].classificationConfidence).toBe( + "low", + ); + }); + + it("an output with its own verdict keeps it — no confidence bleed from the input", () => { + // A fresh classify result carries its own labels; stamping the input's + // heuristic confidence onto them would mislabel an AI verdict as unsure. + const start = stateWith([ + stub("a", { + classificationLabels: ["Invoice"], + classificationConfidence: "low", + }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [ + stub("b", { classificationLabels: ["Contract"] }), + ], + }, + }); + expect(next.files.byId["b" as FileId].classificationLabels).toEqual([ + "Contract", + ]); + expect( + next.files.byId["b" as FileId].classificationConfidence, + ).toBeUndefined(); + }); + it("non-silent CONSUME_FILES still moves the output to the front (unchanged)", () => { const start = stateWith([stub("a"), stub("b")]); const next = fileContextReducer(start, { diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index a9dcae7b38..1ed23b11d6 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -386,14 +386,19 @@ export function fileContextReducer( ), ); - // Carry the document's classification labels forward across the edit: any + // Carry the document's classification verdict forward across the edit: any // tool that versions/derives a classified file keeps it in its label // groups instead of dropping to "Other" and waiting on a PDF re-read. - // Inherited from the first input that has any; an output that already - // carries its own (e.g. a fresh classify result) keeps them. - const inheritedLabels = inputFileIds - .map((id) => state.files.byId[id]?.classificationLabels) - .find((labels) => labels && labels.length > 0); + // Inherited from the first input that has labels, together with that + // verdict's confidence - the escalation decision (shouldDispatchToAi) is + // about the document, not about which step produced the current bytes, so + // it must survive the version boundary. An output that already carries its + // own verdict (e.g. a fresh classify result) keeps it. + const verdictDonor = inputFileIds + .map((id) => state.files.byId[id]) + .find( + (s) => s?.classificationLabels && s.classificationLabels.length > 0, + ); // Mark every consume output as tool-produced (the single chokepoint for // both versioned edits and independent artifacts like convert/split/merge) @@ -404,7 +409,14 @@ export function fileContextReducer( ...stub, derivedFromTool: true, sourceFileIds, - classificationLabels: stub.classificationLabels ?? inheritedLabels, + ...(stub.classificationLabels == null && verdictDonor + ? { + classificationLabels: verdictDonor.classificationLabels, + classificationConfidence: + stub.classificationConfidence ?? + verdictDonor.classificationConfidence, + } + : {}), })); // Silent (background enforcement): replace inputs in their existing grid diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts index 498a688ac2..3ee9057b3e 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -83,6 +83,29 @@ describe("policyRunStore", () => { expect(isDispatched("security", "f1")).toBe(true); }); + it("a browser-local run does not claim the (policy, file) dispatch key", () => { + // The local classification heuristic records a run for the same (classification, file) pair + // the server escalation is keyed on. If that claimed the key, the auto-run would read + // "already dispatched" and never ask the AI - which killed escalation entirely. + recordRunStart( + rec({ + runId: "local-classification-f1-1", + categoryId: "classification", + fileId: "f1", + target: "local", + browserLocal: true, + status: "RUNNING", + }), + ); + expect(getRun("local-classification-f1-1")).toBeDefined(); + expect(isDispatched("classification", "f1")).toBe(false); + }); + + it("a real backend run still claims the dispatch key", () => { + recordRunStart(rec({ runId: "srv-1", categoryId: "classification" })); + expect(isDispatched("classification", "f1")).toBe(true); + }); + it("never evicts in-flight runs, even past the soft cap", () => { // A large upload batch can exceed the cap while still processing. Dropping a // live run would orphan its polling/import and undercount progress, so every diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 2fd91d215a..a598b8b9b1 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -47,6 +47,14 @@ export interface PolicyRunRecord { retrying?: boolean; /** Epoch ms when the run was dispatched. */ startedAt: number; + /** + * Ran in the browser (the local classification heuristic), not on a backend. Such a run has no + * server-side status to poll, and - crucially - must NOT claim the (policy, file) dispatch key: + * it is the first pass, not the policy's run, so claiming it would suppress the server run the + * verdict may still need to escalate to. Distinct from {@link target}, which says which BACKEND + * holds a real run's outputs. + */ + browserLocal?: boolean; } /** Statuses of a run that is still executing (not yet settled). */ @@ -216,11 +224,15 @@ export function recordRunStart(record: PolicyRunRecord) { const waveStartedAt = state.runs.some(isRunInFlight) ? state.waveStartedAt : record.startedAt; + // A browser-local run is the first pass, not the policy's run: claiming the dispatch key here + // would permanently suppress the server run its verdict may still need to escalate to. + const claimsDispatch = !record.browserLocal; state = { runs: capRuns([record, ...state.runs]), - dispatched: state.dispatched.includes(key) - ? state.dispatched - : [...state.dispatched, key], + dispatched: + !claimsDispatch || state.dispatched.includes(key) + ? state.dispatched + : [...state.dispatched, key], waveStartedAt, }; emit(); diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx index d399b09553..2149161f59 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx @@ -77,7 +77,10 @@ vi.mock("@app/services/classificationMeter", () => ({ meterClassificationRun: (payload: unknown) => mocks.meter(payload), })); -import { useClientSideClassification } from "@app/components/policies/useClientSideClassification"; +import { + useClientSideClassification, + LOCAL_METER_CATEGORY, +} from "@app/components/policies/useClientSideClassification"; // Run idle callbacks immediately so batches start without timer waits. vi.stubGlobal("requestIdleCallback", (cb: () => void) => { @@ -181,8 +184,9 @@ describe("useClientSideClassification delivery", () => { }); it("heals a previously-dispatched file whose result was lost, without re-metering", async () => { - // A past session classified + metered this file but the delivery was lost. - markDispatched("classification", "lost"); + // A past session classified + metered this file but the delivery was lost. The marker is the + // local-meter key, NOT the classification dispatch key - that one belongs to the server run. + markDispatched(LOCAL_METER_CATEGORY, "lost"); mocks.workspace = [stub("lost")]; mocks.classify.mockResolvedValue({ labels: ["bank-statement"] }); diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index ede1768b2a..0ae9238b12 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -22,6 +22,12 @@ import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { HeuristicConfidence } from "@app/services/heuristic/types"; import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; +/** + * Dispatch-store key namespace for "this file's local pass has been metered". Deliberately NOT the + * Classification category id: that key is the server escalation's own guard, so metering under it + * would tell the auto-run the policy had already run and kill the escalation entirely. + */ +export const LOCAL_METER_CATEGORY = `${CLASSIFICATION_CATEGORY_ID}:local-meter`; /** Files classified per idle pass, so a large library drains over several ticks. */ const CLASSIFY_BATCH = 3; /** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). @@ -161,9 +167,7 @@ async function classifyStub( // A local run is still a billable policy run, so it belongs in the activity feed; recorded only // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. - // Read before recordRunStart, which takes the dispatch key itself and would otherwise always - // answer "already dispatched", silently stopping metering. - const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + const alreadyMetered = isDispatched(LOCAL_METER_CATEGORY, fileId); const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; recordRunStart({ runId, @@ -172,6 +176,9 @@ async function classifyStub( fileName, fileSize, target: "local", + // Ran here, not on a backend: nothing to poll, and it must not claim the classification + // dispatch key - that key is what the server escalation checks before running. + browserLocal: true, status: "RUNNING", outputs: [], error: null, @@ -201,7 +208,7 @@ async function classifyStub( labels, }); } - markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + markDispatched(LOCAL_METER_CATEGORY, fileId); // Labels, no output file - the same settle shape the server-run classification uses. updateRun(runId, { status: "COMPLETED", diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx index a6f78392be..a03d55f757 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx @@ -10,7 +10,13 @@ const aiEnabled = vi.hoisted(() => ({ value: true })); vi.mock("@app/hooks/useAiEngineEnabled", () => ({ useAiEngineEnabled: () => aiEnabled.value, })); -const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = []; +const fileStubs: { + id: string; + name: string; + derivedFromTool?: boolean; + classificationLabels?: string[]; + classificationConfidence?: "none" | "low" | "medium" | "high"; +}[] = []; vi.mock("@app/contexts/FileContext", () => ({ useAllFiles: () => ({ fileStubs }), useFileManagement: () => ({ addFiles: vi.fn() }), @@ -55,10 +61,11 @@ import { updateRun, resetPolicyRuns, } from "@app/components/policies/policyRunStore"; -import { runStoredPolicy } from "@app/services/policyApi"; +import { runStoredPolicy, getPolicyRun } from "@app/services/policyApi"; import { fileStorage } from "@app/services/fileStorage"; const runStored = vi.mocked(runStoredPolicy); +const getPolicyRunMock = vi.mocked(getPolicyRun); const getFile = vi.mocked(fileStorage.getStirlingFile); /** Reset the shared file list between tests without swapping the array identity. */ @@ -67,6 +74,27 @@ function setFileStubs(next: typeof fileStubs) { fileStubs.push(...next); } +/** A completed security run whose imported output is file-1-v2, ready to chain from. */ +function seedCompletedSecurityRun() { + recordRunStart({ + runId: "run-sec", + categoryId: "security", + fileId: "file-1", + fileName: "doc.pdf", + fileSize: 100, + target: "saas", + status: "PENDING", + outputs: [], + error: null, + startedAt: 0, + }); + updateRun("run-sec", { + status: "COMPLETED", + imported: true, + outputFileIds: ["file-1-v2"], + }); +} + beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); @@ -133,6 +161,154 @@ describe("auto-run ordered chaining", () => { ); }); + it("escalates a chained output that carries no verdict", async () => { + // The output stub is in the workspace shaped as a new_file-mode delivery (or a + // version made before the upload's verdict landed) produces it: tool-derived, + // labels inherited, NO classificationConfidence. No local pass ever runs on a + // derived file, so waiting for a verdict would skip classification forever — + // it must dispatch to the engine instead. + seedCompletedSecurityRun(); + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationLabels: ["invoice"], + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); + }); + + it("chains classification onto an output that inherited an unsure verdict", async () => { + // The default (new_version) delivery: createChildStub copies the parent's + // verdict onto the output, so a low confidence rides through and escalates. + seedCompletedSecurityRun(); + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationLabels: ["invoice"], + classificationConfidence: "low", + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); + }); + + it("lets an inherited confident verdict stand — no engine call for the chained output", async () => { + seedCompletedSecurityRun(); + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationLabels: ["invoice"], + classificationConfidence: "high", + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect( + runStored.mock.calls.some(([backendId]) => backendId === "backend-cls"), + ).toBe(false); + }); + + it("still escalates after the local pass has recorded its own run for the file", async () => { + // The regression that made the whole escalation dead in practice: the local heuristic records + // a run for the SAME (classification, file) pair, and recordRunStart claims the dispatch key. + // The auto-run then reads "already dispatched" and skips the server run forever. A + // browser-local run must not claim that key - it is the first pass, not the policy's run. + seedCompletedSecurityRun(); + // The local pass ran on the chained output and recorded its own run for it. + recordRunStart({ + runId: "local-classification-file-1-v2-123", + categoryId: "classification", + fileId: "file-1-v2", + fileName: "doc.pdf", + fileSize: 100, + target: "local", + browserLocal: true, + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + }); + // Its verdict was unsure, so the AI must still be asked. + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationConfidence: "low", + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); + }); + + it("does not poll a browser-local run against the server", async () => { + // There is no server-side run to ask about: polling 404s, and MAX_NOT_FOUND consecutive + // misses would mark a local run that actually succeeded as FAILED. + recordRunStart({ + runId: "local-classification-file-9-456", + categoryId: "classification", + fileId: "file-9", + fileName: "doc.pdf", + fileSize: 100, + target: "local", + browserLocal: true, + status: "RUNNING", + outputs: [], + error: null, + startedAt: 0, + }); + setFileStubs([]); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(getPolicyRunMock).not.toHaveBeenCalled(); + }); + it("keeps classification out of the server chain when the AI engine is off", async () => { // AI off: classification runs client-side (useClientSideClassification), so the // server chain must skip it - only the normal (security) policy dispatches. @@ -150,11 +326,9 @@ describe("auto-run ordered chaining", () => { [{ size: 100 }], "file-1", ); - expect(runStored).not.toHaveBeenCalledWith( - "backend-cls", - expect.anything(), - expect.anything(), - ); + expect( + runStored.mock.calls.some(([backendId]) => backendId === "backend-cls"), + ).toBe(false); }); it("never dispatches on a file marked derivedFromTool", async () => { diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx new file mode 100644 index 0000000000..d3eec22b5a --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx @@ -0,0 +1,155 @@ +/** + * The default shipped setup: Classification is the ONLY upload policy, so it dispatches directly + * on the upload rather than through the chain. This is the configuration the escalation was built + * for, and the one where it was completely dead: the browser-side first pass records its own run + * for the same (classification, file) pair, and recordRunStart claims the dispatch key, so the + * auto-run read "already dispatched" and never asked the AI - whatever the verdict said. + * + * Driven against the REAL run store; mocking the store is what let the regression through. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +const fileStubs: { + id: string; + name: string; + derivedFromTool?: boolean; + classificationLabels?: string[]; + classificationConfidence?: "none" | "low" | "medium" | "high"; +}[] = []; +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs }), + useFileManagement: () => ({ addFiles: vi.fn() }), + useFileContext: () => ({ consumeFiles: vi.fn() }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + classification: { + configured: true, + status: "active", + backendId: "backend-cls", + runOn: "upload", + order: 0, + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() }, +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: vi.fn() }), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + recordRunStart, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import { runStoredPolicy } from "@app/services/policyApi"; +import { fileStorage } from "@app/services/fileStorage"; + +const runStored = vi.mocked(runStoredPolicy); +const getFile = vi.mocked(fileStorage.getStirlingFile); + +function setFileStubs(next: typeof fileStubs) { + fileStubs.length = 0; + fileStubs.push(...next); +} + +/** + * Exactly what useClientSideClassification does when its heuristic pass finishes: a run row for + * the activity feed, categorised as classification, for the file it just read. + */ +function recordLocalPassFor(fileId: string) { + recordRunStart({ + runId: `local-classification-${fileId}-1`, + categoryId: "classification", + fileId, + fileName: "low-confidence-classification-test.pdf", + fileSize: 1460, + target: "local", + browserLocal: true, + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + resetPolicyRuns(); + runStored.mockReset(); + runStored.mockResolvedValue("run-cls"); + getFile.mockReset(); + getFile.mockResolvedValue({ size: 1460 } as never); + setFileStubs([]); +}); +afterEach(() => vi.useRealTimers()); + +async function render() { + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); +} + +describe("classification escalation (single-policy setup)", () => { + it("asks the AI about an unsure verdict even though the local pass already ran", async () => { + // low-confidence-classification-test.pdf: the heuristic emits labels but only at "low". + recordLocalPassFor("file-1"); + setFileStubs([ + { + id: "file-1", + name: "low-confidence-classification-test.pdf", + classificationLabels: ["contract", "invoice"], + classificationConfidence: "low", + }, + ]); + + await render(); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 1460 }], + "file-1", + ); + }); + + it("leaves a confident local verdict alone (no engine call, no charge)", async () => { + recordLocalPassFor("file-2"); + setFileStubs([ + { + id: "file-2", + name: "invoice.pdf", + classificationLabels: ["invoice"], + classificationConfidence: "high", + }, + ]); + + await render(); + + expect(runStored).not.toHaveBeenCalled(); + }); + + it("waits for the verdict rather than racing the local pass", async () => { + // No verdict yet on a plain upload: dispatching now would pay for an answer the free + // first pass is about to produce. The effect re-runs when the verdict lands. + setFileStubs([{ id: "file-3", name: "unknown.pdf" }]); + + await render(); + + expect(runStored).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 0638b1432c..fa3fcf13d3 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -308,8 +308,10 @@ export function usePolicyAutoRun(): void { for (const outputId of outputIds) { if (isDispatched(nextCategory, outputId as FileId)) continue; const outputStub = stubsRef.current.find((s) => s.id === outputId); - // Nothing to escalate: either the heuristic already answered confidently, or it has - // not reported yet and this effect re-runs when the verdict lands. + // The output's inherited verdict decides here and now (no local pass ever runs + // on a derived file, so there is nothing to defer to): a confident one stands, + // anything else - including no verdict at all, e.g. a new_file-mode delivery - + // escalates. A stub not yet in the snapshot falls through to dispatch too. if (outputStub && !shouldDispatchToAi(nextCategory, outputStub)) continue; void runPolicyOnFile( @@ -326,6 +328,9 @@ export function usePolicyAutoRun(): void { // Poll each in-flight run to a terminal state. useEffect(() => { for (const run of runs) { + // A browser-local run has no server-side status: polling it 404s (and after MAX_NOT_FOUND + // marks a run that actually succeeded as failed). Its own pass settles it. + if (run.browserLocal) continue; if (isTerminal(run.status) || polling.current.has(run.runId)) continue; polling.current.add(run.runId); void poll(run.runId, onRunFinished).finally(() => diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts index d3cb55c043..cb5dfec779 100644 --- a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts +++ b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts @@ -14,6 +14,14 @@ const stub = ( ): StirlingFileStub => ({ classificationConfidence: confidence }) as StirlingFileStub; +const derivedStub = ( + confidence?: StirlingFileStub["classificationConfidence"], +): StirlingFileStub => + ({ + derivedFromTool: true, + classificationConfidence: confidence, + }) as StirlingFileStub; + describe("isClassificationCategory", () => { it("recognises the classification category and nothing else", () => { expect(isClassificationCategory("classification")).toBe(true); @@ -88,4 +96,19 @@ describe("shouldDispatchToAi", () => { expect(shouldDispatchToAi("classification", stub("low"))).toBe(true); expect(shouldDispatchToAi("classification", stub("none"))).toBe(true); }); + + it("escalates a tool-derived file with no verdict at all", () => { + // A derived file gets no local pass (useClientSideClassification skips it), so + // there is no verdict to wait for: holding back would skip it forever. This is + // the chained case for a new_file-mode output, or a version made before the + // upload's verdict landed. + expect(shouldDispatchToAi("classification", derivedStub())).toBe(true); + }); + + it("lets a derived file's inherited verdict decide like an upload's own", () => { + expect(shouldDispatchToAi("classification", derivedStub("high"))).toBe( + false, + ); + expect(shouldDispatchToAi("classification", derivedStub("low"))).toBe(true); + }); }); diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts index 4327b40d39..60ef0de23f 100644 --- a/frontend/editor/src/proprietary/data/classificationPolicy.ts +++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts @@ -56,8 +56,11 @@ export function orderRewritesFirst(categoryIds: string[]): string[] { const TRUSTED_CONFIDENCE: ClassificationConfidence = "high"; /** - * Whether the AI classifier should be asked about this file. Only once the heuristic has reported: - * dispatching before then races the first pass and bills for an answer it was about to produce. + * Whether the AI classifier should be asked about this file. For an upload, only once the + * heuristic has reported: dispatching before then races the first pass and bills for an answer it + * was about to produce. A tool-derived file gets no local pass (useClientSideClassification skips + * it) and only ever carries an inherited verdict, so an absent verdict there is permanent - + * escalate rather than wait for a report that will never come. */ export function shouldDispatchToAi( categoryId: string, @@ -65,5 +68,6 @@ export function shouldDispatchToAi( ): boolean { if (!isClassificationCategory(categoryId)) return true; const confidence = stub.classificationConfidence; - return confidence != null && confidence !== TRUSTED_CONFIDENCE; + if (confidence == null) return Boolean(stub.derivedFromTool); + return confidence !== TRUSTED_CONFIDENCE; } diff --git a/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf b/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf new file mode 100644 index 0000000000..9f1e9852ea --- /dev/null +++ b/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf @@ -0,0 +1,95 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 782 >> +stream +BT +/F1 20 Tf +72 720 Td +(Summary Document) Tj +ET +BT +/F1 12 Tf +72 680 Td +(The parties hereto acknowledge the position set out below.) Tj +ET +BT +/F1 12 Tf +72 658 Td +(Term and termination provisions apply.) Tj +ET +BT +/F1 12 Tf +72 636 Td +(Hereinafter referred to as the Supplier.) Tj +ET +BT +/F1 12 Tf +72 614 Td +(Amount due: 1,250.00) Tj +ET +BT +/F1 12 Tf +72 592 Td +(Payment terms apply.) Tj +ET +BT +/F1 12 Tf +72 570 Td +(Total payable: 1,250.00) Tj +ET +BT +/F1 12 Tf +72 548 Td +(Balance due: 1,250.00) Tj +ET +BT +/F1 12 Tf +72 526 Td +(This document has been prepared for internal review.) Tj +ET +BT +/F1 12 Tf +72 504 Td +(Please retain a copy for your records.) Tj +ET +BT +/F1 12 Tf +72 482 Td +(Reference: SD-2024-0417.) Tj +ET +BT +/F1 12 Tf +72 460 Td +(Prepared by the operations team on 17 April 2024.) Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Title (Summary Document) /Producer (Stirling-PDF classification test fixture) >> +endobj +xref +0 7 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000001074 00000 n +0000001144 00000 n +trailer +<< /Size 7 /Root 1 0 R /Info 6 0 R >> +startxref +1244 +%%EOF diff --git a/frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts b/frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts new file mode 100644 index 0000000000..69b07979f1 --- /dev/null +++ b/frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts @@ -0,0 +1,60 @@ +/** + * Pins the verdict of `low-confidence-classification.pdf`, the manual-repro fixture for the + * classification escalation. The document is only useful as a repro while the heuristic still + * returns something OTHER than "high" for it - a rules change that made it confident would + * silently turn the manual test into a no-op. + * + * The text below is verbatim pdf.js output for that file, so a failure here means the PDF and + * these expectations have drifted apart - re-extract before changing either. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { + classifyHeuristic, + ensureRulesLoaded, +} from "@app/services/heuristic/heuristicEngine"; +import type { HeuristicDoc } from "@app/services/heuristic/types"; + +const EXTRACTED = + "Summary Document The parties hereto acknowledge the position set out below. " + + "Term and termination provisions apply. Hereinafter referred to as the Supplier. " + + "Amount due: 1,250.00 Payment terms apply. Total payable: 1,250.00 " + + "Balance due: 1,250.00 This document has been prepared for internal review. " + + "Please retain a copy for your records. Reference: SD-2024-0417. " + + "Prepared by the operations team on 17 April 2024."; + +beforeAll(async () => { + await ensureRulesLoaded(); +}); + +describe("low-confidence-classification.pdf fixture", () => { + const doc: HeuristicDoc = { + fileName: "low-confidence-classification.pdf", + pageCount: 1, + meta: { Title: "Summary Document" }, + titleZone: "Summary Document", + firstZone: EXTRACTED, + allZone: EXTRACTED, + }; + + it("is English, so it is not rejected before scoring", () => { + expect(classifyHeuristic(doc).isEnglish).toBe(true); + }); + + it("emits labels but is not trusted, so it must escalate", () => { + const r = classifyHeuristic(doc); + expect(r.labels.length).toBeGreaterThan(0); + expect(r.confidence).not.toBe("high"); + }); + + it("stays unsure because two document types score within the medium margin", () => { + // The margin is what holds this document at "low": "medium" needs >= 8 and "high" >= 15, + // so a near-tie can't be promoted however high the raw scores go. + const r = classifyHeuristic(doc, { explain: true }); + const [first, second] = r.explain?.candidates ?? []; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first.score - second.score).toBeLessThan(8); + // ...and comfortably clear of the floor, so it doesn't collapse to "no label" either. + expect(first.score).toBeGreaterThanOrEqual(28); + }); +}); From f7a2c626c9a62993265127488cbaa9b7e8e350cc Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:12:48 +0000 Subject: [PATCH 03/17] Persist the workbench session across the editor/processor switch (#7654) ## What Switching editor -> processor (or reloading) unmounts every editor provider, which emptied the workbench. This PR mirrors the workbench into per-tab sessionStorage and refills an empty one from that record on the next mount: - **Files, selection, view and active document survive** the shell switch and reloads. Each recorded file is resolved to its *current leaf* version on restore, so a file versioned by a policy or another tab comes back at its latest state. - **The switch back lands where the user left**: the processor sidebar's "editor" button consumes a one-shot return path saved at switch time. - **The app switch respects unsaved changes**: `useOtherAppSwitch` (proprietary + saas) now routes through `requestNavigation`, so the same warning guards it as any other navigation. - Desktop shadows `WorkbenchSessionPersistence` with a stub (OS-launched files own boot there). ## How to test I've run through each of these manually: - Upload several PDFs in the editor, select a couple, and switch to the Active Files grid. Click "Open PDF Processor" in the sidebar footer, then switch back to the editor. The same files, selection and view should return, and you should land on the editor page you left. - Open a document in the viewer, then reload the tab. The workbench should refill and come back on the viewer with the same document active. - With unsaved changes in a tool, click the processor switch. The unsaved-changes warning should appear, and the switch should only proceed if you confirm. - Open a second browser tab with different files. Each tab should restore its own workbench independently (the record is per-tab sessionStorage). - While in the processor, delete one of the open files from storage, then switch back. The remaining files should restore and a warning toast should report "Restored X of Y files". --------- Co-authored-by: Claude --- .../public/locales/en-US/translation.toml | 4 + .../src/core/components/AppProviders.tsx | 2 + .../WorkbenchSessionPersistence.test.tsx | 475 ++++++++++++++++++ .../session/WorkbenchSessionPersistence.tsx | 301 +++++++++++ .../editor/src/core/constants/featureFlags.ts | 3 + .../src/core/contexts/NavigationContext.tsx | 9 + .../src/core/extensions/accountLogout.ts | 6 + frontend/editor/src/core/pages/HomePage.tsx | 15 +- .../core/services/workbenchSession.test.ts | 193 +++++++ .../src/core/services/workbenchSession.ts | 173 +++++++ .../tests/stubbed/engine-capabilities.spec.ts | 6 + .../stubbed/workbench-session-restore.spec.ts | 207 ++++++++ .../session/WorkbenchSessionPersistence.tsx | 4 + .../src/desktop/extensions/accountLogout.ts | 5 + .../editor/src/portal/components/Sidebar.tsx | 3 +- .../proprietary/auth/spring/UseSession.tsx | 6 + .../usePolicyAutoRun.reentry.test.tsx | 119 +++++ .../src/proprietary/constants/featureFlags.ts | 3 + .../proprietary/extensions/accountLogout.ts | 6 + .../hooks/useOtherAppSwitch.test.tsx | 55 ++ .../proprietary/hooks/useOtherAppSwitch.ts | 16 +- .../src/saas/auth/AuthProvider.test.tsx | 58 ++- frontend/editor/src/saas/auth/UseSession.tsx | 13 + .../src/saas/hooks/useOtherAppSwitch.ts | 16 +- 24 files changed, 1689 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx create mode 100644 frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx create mode 100644 frontend/editor/src/core/services/workbenchSession.test.ts create mode 100644 frontend/editor/src/core/services/workbenchSession.ts create mode 100644 frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts create mode 100644 frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx create mode 100644 frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1b81a551f1..540836032f 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -11797,6 +11797,10 @@ title = "Watermark Text" image = "Image" text = "Text" +[workbench.sessionRestore] +none = "Your previous files are no longer stored on this device." +partial = "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device." + [workbenchBar] activeFiles = "Active Files" annotations = "Annotations" diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 01b2e42a82..ed1194eb4b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -39,6 +39,7 @@ import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; import { FolderFileContextProvider } from "@app/contexts/FolderFileContext"; import { FolderProvider } from "@app/contexts/FolderContext"; +import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -163,6 +164,7 @@ export function AppProviders({ + {children} diff --git a/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx new file mode 100644 index 0000000000..b05928a93e --- /dev/null +++ b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, waitFor, act } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + getLeafStirlingFileStubs: vi.fn(), + alert: vi.fn(), + setActiveFileId: vi.fn(), + restoreWorkbench: vi.fn(), + workbench: "viewer" as string, + authUser: null as { id: string } | null, + authLoading: false, + pathname: "/editor", + activeFileId: null as string | null, +})); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getLeafStirlingFileStubs: mocks.getLeafStirlingFileStubs }, +})); +vi.mock("@app/components/toast", () => ({ alert: mocks.alert })); +vi.mock("@app/contexts/NavigationContext", () => ({ + useNavigationState: () => ({ workbench: mocks.workbench }), + useNavigationActions: () => ({ + actions: { restoreWorkbench: mocks.restoreWorkbench }, + }), +})); +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: mocks.pathname }), +})); +vi.mock("@app/auth/UseSession", () => ({ + useAuth: () => ({ user: mocks.authUser, loading: mocks.authLoading }), +})); +vi.mock("@app/contexts/ViewerContext", () => ({ + useViewer: () => ({ + activeFileId: mocks.activeFileId, + setActiveFileId: mocks.setActiveFileId, + }), +})); + +import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence"; +import { fingerprintOwner } from "@app/services/workbenchSession"; +import { + FileStoreContext, + FileActionsContext, +} from "@app/contexts/file/contexts"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; + +function stub( + id: string, + originalFileId: string, + versionNumber = 1, +): StirlingFileStub { + return { id, originalFileId, versionNumber, name: `${id}.pdf` } as never; +} + +// A minimal stand-in for the FileContext store: mutable state plus subscribers. +function makeStore(open: StirlingFileStub[] = [], selected: string[] = []) { + const listeners = new Set<() => void>(); + const state = { + files: { + ids: open.map((s) => s.id), + byId: Object.fromEntries(open.map((s) => [s.id, s])), + }, + ui: { selectedFileIds: selected }, + }; + return { + state, + getState: () => state as never, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + // reopenView waits on this to know the restored bytes have landed. + selectors: { + getFiles: (ids: string[]) => ids.map((id) => ({ id })), + } as never, + notify: () => listeners.forEach((listener) => listener()), + }; +} + +const actions = { + addStirlingFileStubs: vi.fn().mockResolvedValue([]), + setSelectedFiles: vi.fn(), +}; + +function mount(store: ReturnType) { + return render( + + + + + , + ); +} + +beforeEach(() => { + // The shared setup stubs crypto.subtle.digest to one constant for every input, so every account + // would fingerprint alike - and ownership is exactly what these tests are about. + vi.spyOn(globalThis.crypto.subtle, "digest").mockImplementation( + async (_algorithm: AlgorithmIdentifier, data: BufferSource) => { + const bytes = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data); + let hash = 0x811c9dc5; + for (const byte of bytes) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + const out = new Uint8Array(32); + for (let i = 0; i < out.length; i++) { + hash = Math.imul(hash ^ i, 0x01000193) >>> 0; + out[i] = hash & 0xff; + } + return out.buffer; + }, + ); + sessionStorage.clear(); + vi.clearAllMocks(); + actions.addStirlingFileStubs.mockResolvedValue([]); + mocks.getLeafStirlingFileStubs.mockResolvedValue([]); + mocks.workbench = "viewer"; + mocks.authUser = null; + mocks.authLoading = false; + mocks.pathname = "/editor"; + mocks.activeFileId = null; +}); +afterEach(() => vi.useRealTimers()); + +describe("restore", () => { + it("refills an empty workbench with each file's current leaf, in saved order", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a", "root-b"], + selectedFileIds: ["root-b"], + }), + ); + // root-a forked while the user was away: v3 must win over the stale v1 leaf. + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("a-v1", "root-a", 1), + stub("a-v3", "root-a", 3), + stub("root-b", "root-b", 1), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + const restored = actions.addStirlingFileStubs.mock.calls[0][0]; + expect(restored.map((s: StirlingFileStub) => s.id)).toEqual([ + "a-v3", + "root-b", + ]); + expect(actions.setSelectedFiles).toHaveBeenCalledWith(["root-b"]); + expect(mocks.alert).not.toHaveBeenCalled(); + }); + + it("does not touch a workbench that already holds files", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + mount(makeStore([stub("already-open", "already-open")])); + + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + }); + + it("restores what still exists and says how much is gone", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a", "gone"], + selectedFileIds: [], + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + expect(actions.addStirlingFileStubs.mock.calls[0][0]).toHaveLength(1); + expect(mocks.alert.mock.calls[0][0].alertType).toBe("warning"); + }); + + it("does not say 'the rest' when nothing at all could be restored", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["gone-1", "gone-2"], + selectedFileIds: [], + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + expect(mocks.alert.mock.calls[0][0].title).toBe( + "workbench.sessionRestore.none", + ); + }); + + it("does nothing when no session was recorded", async () => { + mount(makeStore()); + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + expect(mocks.getLeafStirlingFileStubs).not.toHaveBeenCalled(); + }); + + it("reopens the document the user was viewing, at its current version", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: ["root-a"], + workbench: "fileEditor", + activeFileId: "root-a", + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("a-v2", "root-a", 2), + ]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.setActiveFileId).toHaveBeenCalled()); + expect(mocks.setActiveFileId).toHaveBeenCalledWith("a-v2"); + expect(mocks.restoreWorkbench).toHaveBeenCalledWith("fileEditor"); + }); + + it("leaves a URL-owned view to the return path", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + workbench: "myFiles", + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + expect(mocks.restoreWorkbench).not.toHaveBeenCalled(); + }); +}); + +describe("whose workbench it is", () => { + // Records hold a fingerprint of the owner, never the account id. + const record = async (userId: string | null) => + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + userId: userId == null ? null : await fingerprintOwner(userId), + }), + ); + + it("does not open one user's workbench for the next person in the tab", async () => { + await record("user-a"); + mocks.authUser = { id: "user-b" }; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + // The record is theirs now - the previous person's files are gone from it, so they cannot + // resurface later in the session. + const taken = JSON.parse(sessionStorage.getItem(SESSION_KEY)!); + expect(taken.fileIds).toEqual([]); + expect(taken.userId).toBe(await fingerprintOwner("user-b")); + }); + + it("reopens it for the user who left it", async () => { + await record("user-a"); + mocks.authUser = { id: "user-a" }; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + }); + + it("waits for the session before deciding", async () => { + await record("user-a"); + mocks.authUser = null; + mocks.authLoading = true; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await act(async () => {}); + // Neither restored nor discarded - who is signed in is not known yet. + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull(); + }); +}); + +describe("a lost session that comes back", () => { + const rerenderWith = ( + view: ReturnType, + store: ReturnType, + ) => + view.rerender( + + + + + , + ); + + it("survives a blip on the identity check", async () => { + // A failed /auth/me - flaky wifi, a backend redeploy, a refreshSession() that did not land - + // briefly reads as nobody signed in. It must not be mistaken for signing out. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + userId: await fingerprintOwner("user-a"), + }), + ); + mocks.authUser = { id: "user-a" }; + const store = makeStore([stub("f1", "f1")]); + const view = mount(store); + await act(async () => {}); + + mocks.authUser = null; + rerenderWith(view, store); + await act(async () => {}); + + expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull(); + + // ...and once the identity is back, the workbench is still being recorded. + mocks.authUser = { id: "user-a" }; + rerenderWith(view, store); + // Let the fingerprint land: writes hold off while a known identity has none yet. + await act(async () => {}); + store.state.files.ids = ["f2" as never]; + store.state.files.byId = { f2: stub("f2", "root-b") } as never; + act(() => store.notify()); + view.unmount(); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-b", + ]); + }); +}); + +describe("on the login screen", () => { + it("neither restores nor records - signing out must not rebuild the workbench there", async () => { + mocks.pathname = "/login"; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + const store = makeStore(); + const { unmount } = mount(store); + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + + // And the unmount flush must not write either. + store.state.files.ids = ["f1" as never]; + store.state.files.byId = { f1: stub("f1", "f1") } as never; + act(() => store.notify()); + unmount(); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-a", + ]); + }); +}); + +describe("writer", () => { + it("mirrors the open files and selection as original ids, debounced", async () => { + vi.useFakeTimers(); + const store = makeStore(); + mount(store); + + store.state.files.ids = ["v2" as never]; + store.state.files.byId = { v2: stub("v2", "root-a", 2) } as never; + store.state.ui.selectedFileIds = ["v2"]; + act(() => store.notify()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!)).toMatchObject({ + fileIds: ["root-a"], + selectedFileIds: ["root-a"], + workbench: "viewer", + }); + }); + + it("records the current view, so the return lands where the user left", async () => { + vi.useFakeTimers(); + mocks.workbench = "fileEditor"; + const store = makeStore([stub("f1", "f1")]); + mount(store); + + act(() => store.notify()); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).workbench).toBe( + "fileEditor", + ); + }); + + it("writes nothing until the restore has settled", () => { + vi.useFakeTimers(); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + // Restore is still awaiting storage, so this mount's empty state is not the truth. + mocks.getLeafStirlingFileStubs.mockReturnValue(new Promise(() => {})); + + const store = makeStore(); + const { unmount } = mount(store); + act(() => store.notify()); + unmount(); + + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-a", + ]); + }); + + it("flushes on unmount, so the state at the shell switch survives", () => { + vi.useFakeTimers(); + const store = makeStore(); + const { unmount } = mount(store); + + store.state.files.ids = ["f1" as never]; + store.state.files.byId = { f1: stub("f1", "f1") } as never; + act(() => store.notify()); + unmount(); + + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "f1", + ]); + }); +}); diff --git a/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx new file mode 100644 index 0000000000..86bf72502c --- /dev/null +++ b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx @@ -0,0 +1,301 @@ +// The editor/processor shell switch unmounts every editor provider, and a reload starts from nothing: +// this mirrors the workbench into sessionStorage and refills an empty one from that record on mount. +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FileStoreContext, + type FileStateStore, +} from "@app/contexts/file/contexts"; +import { useFileActions } from "@app/contexts/FileContext"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useAuth } from "@app/auth/UseSession"; +import { useLocation } from "react-router-dom"; +import { isAuthRoute } from "@app/constants/routes"; +import { fileStorage } from "@app/services/fileStorage"; +import { alert } from "@app/components/toast"; +import { WORKBENCH_SESSION_RESTORE } from "@app/constants/featureFlags"; +import { + beginRestoredView, + clearWorkbenchSession, + fingerprintOwner, + resumeWorkbenchSession, + endRestoredView, + isSeedableView, + originalIdOf, + readWorkbenchSession, + writeWorkbenchSession, +} from "@app/services/workbenchSession"; +import type { WorkbenchType } from "@app/types/workbench"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const WRITE_DEBOUNCE_MS = 300; + +// Current leaf per original id; a forked chain resolves to the highest version. +function leafByOriginalId( + leaves: StirlingFileStub[], +): Map { + const map = new Map(); + for (const leaf of leaves) { + const key = originalIdOf(leaf); + const current = map.get(key); + if (!current || (leaf.versionNumber ?? 1) > (current.versionNumber ?? 1)) { + map.set(key, leaf); + } + } + return map; +} + +/** How long to wait for the NEXT file to hydrate before giving up on holding the view. Restarted on + * each arrival, so a slow device with large documents keeps the view as long as it makes progress. */ +const SETTLE_TIMEOUT_MS = 5000; + +/** Released a beat late, so effects reacting to the same commit still see the restore in progress. */ +const RELEASE_GRACE_MS = 250; + +/** + * Reopen the recorded view, then hold the restore guard until the files have hydrated. + * + * The view is written ONCE. Re-asserting it after hydration would also overwrite a view the user + * picked in the meantime; holding the guard is what keeps HomePage's defaults off it instead. + */ +function reopenView( + store: FileStateStore, + reopen: (view: WorkbenchType) => void, + { + view, + fileCount, + token, + }: { view: WorkbenchType; fileCount: number; token: number }, +): void { + reopen(view); + const loaded = () => + store.selectors.getFiles(store.getState().files.ids).length; + + const release = () => + setTimeout(() => endRestoredView(token), RELEASE_GRACE_MS); + if (loaded() >= fileCount) { + release(); + return; + } + + let timer: ReturnType; + const stop = () => { + clearTimeout(timer); + unsubscribe(); + release(); + }; + const waitForNext = () => { + clearTimeout(timer); + timer = setTimeout(stop, SETTLE_TIMEOUT_MS); + }; + + let seen = loaded(); + const unsubscribe = store.subscribe(() => { + const now = loaded(); + if (now >= fileCount) return stop(); + // Progress, not completion: give the remaining files a fresh window. + if (now > seen) { + seen = now; + waitForNext(); + } + }); + waitForNext(); +} + +export function WorkbenchSessionPersistence() { + const store = useContext(FileStoreContext); + const { actions } = useFileActions(); + const { workbench } = useNavigationState(); + const { actions: navigationActions } = useNavigationActions(); + const { activeFileId, setActiveFileId } = useViewer(); + const { user, loading: authLoading } = useAuth(); + // Login/signup mount the editor's providers too. Nothing there is the user's workbench, so this + // records nothing and restores nothing - otherwise signing out rebuilds it on the login screen. + const onAuthRoute = isAuthRoute(useLocation().pathname); + const userId = user?.id != null ? String(user.id) : null; + // Fingerprinted, never stored raw - see fingerprintOwner. Computed asynchronously, so writes + // hold off until it lands rather than stamping the record "nobody's" and then failing its own + // ownership check. + const [owner, setOwner] = useState(null); + useEffect(() => { + if (userId == null) { + setOwner(null); + return; + } + let cancelled = false; + void fingerprintOwner(userId).then((fingerprint) => { + if (!cancelled) setOwner(fingerprint); + }); + return () => { + cancelled = true; + }; + }, [userId]); + const { t } = useTranslation(); + // Captured before the writer below can overwrite it with the empty boot state. + const [saved] = useState(readWorkbenchSession); + const restoreStarted = useRef(false); + // Until the restore has run, this mount's empty state is not the truth to record. + const restoreSettled = useRef(false); + + // Published so a build's restore setting is legible without reading the bundle. + useEffect(() => { + document.documentElement.dataset.workbenchRestore = String( + WORKBENCH_SESSION_RESTORE, + ); + }, []); + + const write = useCallback(() => { + if (!store || !restoreSettled.current) return; + // A known identity whose fingerprint has not landed yet: wait, do not stamp it as nobody's. + if (userId != null && owner == null) return; + const state = store.getState(); + const toOriginal = (id: FileId): string | null => { + const stub = state.files.byId[id]; + return stub ? originalIdOf(stub) : null; + }; + const isPresent = (id: string | null): id is string => id !== null; + writeWorkbenchSession({ + fileIds: state.files.ids.map(toOriginal).filter(isPresent), + selectedFileIds: state.ui.selectedFileIds + .map(toOriginal) + .filter(isPresent), + workbench, + userId: owner, + activeFileId: activeFileId + ? (toOriginal(activeFileId as FileId) ?? undefined) + : undefined, + }); + }, [store, workbench, activeFileId, userId, owner]); + + // Read by the file subscription, which must not resubscribe on every view change. + const writeRef = useRef(write); + writeRef.current = write; + + useEffect(() => { + if (!store || onAuthRoute) return; + // This mount is a new session: undo any suspension left by a sign-out in this page's lifetime. + resumeWorkbenchSession(); + let timer: ReturnType | undefined; + const unsubscribe = store.subscribe(() => { + clearTimeout(timer); + timer = setTimeout(() => writeRef.current(), WRITE_DEBOUNCE_MS); + }); + return () => { + clearTimeout(timer); + // Flush, so the state at the moment of the shell switch is what survives. + writeRef.current(); + unsubscribe(); + }; + }, [store, onAuthRoute]); + + // Changing view touches no file state, so the subscription above never sees it. + useEffect(() => write(), [write]); + + useEffect(() => { + if (restoreStarted.current) return; + if (onAuthRoute) return; + // Who is signed in decides whether this record is theirs to reopen, so settle that first. + if (authLoading) return; + restoreStarted.current = true; + + const nothingToDo = + !WORKBENCH_SESSION_RESTORE || + !store || + !saved || + saved.fileIds.length === 0 || + store.getState().files.ids.length > 0; + if (nothingToDo) { + restoreSettled.current = true; + return; + } + + void (async () => { + // A tab can outlive a sign-out (the logout clears it, but a 401 bounce or an expiry does + // not), and the next person to sign in here must not open the last person's documents. + const currentOwner = + userId == null ? null : await fingerprintOwner(userId); + if ((saved.userId ?? null) !== currentOwner) { + clearWorkbenchSession(); + restoreSettled.current = true; + return; + } + + // Held while the files land: they are added one at a time, and each landing re-runs the + // default-view heuristic, which must not overwrite the recorded view mid-restore. + let held: number | null = null; + try { + // Resolve each id to its CURRENT leaf: a policy or another tab may have versioned it since. + const leaves = leafByOriginalId( + await fileStorage.getLeafStirlingFileStubs(), + ); + const stubs = saved.fileIds + .map((id) => leaves.get(id)) + .filter((stub): stub is StirlingFileStub => stub !== undefined); + + if (stubs.length > 0) { + const view = isSeedableView(saved.workbench) ? saved.workbench : null; + if (view) held = beginRestoredView(); + // The same entry point My Files uses, so a restored file is governed by the same rules as + // any other file entering the workbench - including whether a policy has already run on it. + await actions.addStirlingFileStubs(stubs); + const selected = saved.selectedFileIds + .map((id) => leaves.get(id)?.id) + .filter((id): id is FileId => id !== undefined); + if (selected.length > 0) actions.setSelectedFiles(selected); + // After the files land: the viewer drops an active id it cannot find. + const active = saved.activeFileId + ? leaves.get(saved.activeFileId)?.id + : undefined; + if (active) setActiveFileId(active as string); + if (view && held !== null) { + reopenView(store, navigationActions.restoreWorkbench, { + view, + fileCount: stubs.length, + token: held, + }); + held = null; // reopenView owns the release from here. + } + } + + const missing = saved.fileIds.length - stubs.length; + if (missing > 0) { + alert({ + alertType: "warning", + title: + stubs.length === 0 + ? t( + "workbench.sessionRestore.none", + "Your previous files are no longer stored on this device.", + ) + : t( + "workbench.sessionRestore.partial", + "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device.", + { restored: stubs.length, total: saved.fileIds.length }, + ), + }); + } + } finally { + if (held !== null) endRestoredView(held); + // Even a failed restore must release the writer, or the record freezes for the session. + restoreSettled.current = true; + } + })(); + }, [ + saved, + store, + actions, + navigationActions, + setActiveFileId, + t, + authLoading, + userId, + onAuthRoute, + ]); + + return null; +} diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts index a60770ac3a..8981dce2d6 100644 --- a/frontend/editor/src/core/constants/featureFlags.ts +++ b/frontend/editor/src/core/constants/featureFlags.ts @@ -11,3 +11,6 @@ // Annotated as `boolean` (not the literal `false`) so call sites aren't treated // as constant/unreachable conditions by the type checker and linter. export const WATCHED_FOLDERS_ENABLED: boolean = false; + +// Refill an empty workbench from the tab's last session (survives a provider remount or a reload). +export const WORKBENCH_SESSION_RESTORE: boolean = true; diff --git a/frontend/editor/src/core/contexts/NavigationContext.tsx b/frontend/editor/src/core/contexts/NavigationContext.tsx index a7cb95d349..f158991378 100644 --- a/frontend/editor/src/core/contexts/NavigationContext.tsx +++ b/frontend/editor/src/core/contexts/NavigationContext.tsx @@ -94,6 +94,9 @@ export interface NavigationWarningHandlers { // Navigation context actions interface export interface NavigationContextActions { setWorkbench: (workbench: WorkbenchType) => void; + /** Reopen a view the user already had, bypassing the unsaved-changes prompt that + * guards a user-initiated switch - a restore is not the user leaving anything. */ + restoreWorkbench: (workbench: WorkbenchType) => void; setSelectedTool: (toolId: ToolId | null) => void; setToolAndWorkbench: ( toolId: ToolId | null, @@ -221,6 +224,10 @@ export const NavigationProvider: React.FC<{ [state.workbench, state.hasUnsavedChanges], ); + const restoreWorkbench = useCallback((workbench: WorkbenchType) => { + dispatch({ type: "SET_WORKBENCH", payload: { workbench } }); + }, []); + const setSelectedTool = useCallback((toolId: ToolId | null) => { dispatch({ type: "SET_SELECTED_TOOL", payload: { toolId } }); }, []); @@ -402,6 +409,7 @@ export const NavigationProvider: React.FC<{ const actions: NavigationContextActions = useMemo( () => ({ setWorkbench, + restoreWorkbench, setSelectedTool, setToolAndWorkbench, setHasUnsavedChanges, @@ -419,6 +427,7 @@ export const NavigationProvider: React.FC<{ }), [ setWorkbench, + restoreWorkbench, setSelectedTool, setToolAndWorkbench, setHasUnsavedChanges, diff --git a/frontend/editor/src/core/extensions/accountLogout.ts b/frontend/editor/src/core/extensions/accountLogout.ts index e4eddd7274..df4e8f4273 100644 --- a/frontend/editor/src/core/extensions/accountLogout.ts +++ b/frontend/editor/src/core/extensions/accountLogout.ts @@ -1,3 +1,5 @@ +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; + type SignOutFn = () => Promise; interface AccountLogoutDeps { @@ -21,6 +23,10 @@ export function useAccountLogout() { "1", ); } + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); } finally { redirectToLogin(); diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index e0c5303447..0d7a571f98 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -15,6 +15,7 @@ import { useNavigationState, useNavigationActions, } from "@app/contexts/NavigationContext"; +import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; import { useLocation, useNavigate } from "react-router-dom"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -161,8 +162,14 @@ export default function HomePage() { if (navigationState.workbench !== "myFiles") { actions.setWorkbench("myFiles"); } - } else if (navigationState.workbench === "myFiles") { - // Leaving the file manager - drop back to a sensible default. + } else if ( + navigationState.workbench === "myFiles" && + !isApplyingRestoredView() + ) { + // The URL no longer supports the file manager - drop back to a sensible default. Stays a + // state check rather than a transition one: HomePage remounts without NavigationContext + // (a share link, a login bounce), and the view has to be corrected on arrival too. + // Skipped mid-restore, which is reopening a recorded view onto files still loading. actions.setWorkbench(activeFiles.length > 1 ? "fileEditor" : "viewer"); } }, [ @@ -204,7 +211,9 @@ export default function HomePage() { navigationState.workbench, ); - if (action) { + // A session restore fills an empty workbench too, but it already knows which view the user + // left - so it wins over this heuristic rather than being overwritten by it. + if (action && !isApplyingRestoredView()) { actions.setWorkbench(action.workbench); if (typeof action.activeFileIndex === "number") { setActiveFileIndex(action.activeFileIndex); diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts new file mode 100644 index 0000000000..d9feec8d1c --- /dev/null +++ b/frontend/editor/src/core/services/workbenchSession.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + originalIdOf, + readWorkbenchSession, + writeWorkbenchSession, + saveEditorReturnPath, + takeEditorReturnPath, + isSeedableView, + clearWorkbenchSession, + suspendWorkbenchSession, + resumeWorkbenchSession, +} from "@app/services/workbenchSession"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; + +beforeEach(() => { + sessionStorage.clear(); + resumeWorkbenchSession(); +}); + +describe("workbench session record", () => { + it("round-trips the open files and selection", () => { + writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: ["b"] }); + expect(readWorkbenchSession()).toMatchObject({ + fileIds: ["a", "b"], + selectedFileIds: ["b"], + }); + }); + + it("returns null when nothing was recorded", () => { + expect(readWorkbenchSession()).toBeNull(); + }); + + it("rejects a malformed record instead of throwing", () => { + sessionStorage.setItem(SESSION_KEY, "not json"); + expect(readWorkbenchSession()).toBeNull(); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: "nope" }), + ); + expect(readWorkbenchSession()).toBeNull(); + }); + + it("drops non-string ids and defaults a missing selection", () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["a", 7, null, "b"] }), + ); + expect(readWorkbenchSession()).toMatchObject({ + fileIds: ["a", "b"], + selectedFileIds: [], + }); + }); +}); + +describe("editor return path", () => { + it("is consumed by the first take", () => { + saveEditorReturnPath("/compress?x=1"); + expect(takeEditorReturnPath()).toBe("/compress?x=1"); + expect(takeEditorReturnPath()).toBeNull(); + }); +}); + +describe("originalIdOf", () => { + it("prefers the original id and falls back to the file id", () => { + expect( + originalIdOf({ id: "v3", originalFileId: "root" } as StirlingFileStub), + ).toBe("root"); + expect( + originalIdOf({ id: "v1", originalFileId: "" } as StirlingFileStub), + ).toBe("v1"); + }); +}); + +describe("record hygiene", () => { + it("discards a record written by an older schema", () => { + // No version stamp: a shape this build no longer understands. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ fileIds: ["a"], selectedFileIds: [] }), + ); + expect(readWorkbenchSession()).toBeNull(); + + // v1 recorded userId before it meant anything, so those must go too rather than + // look like a workbench that legitimately belongs to an anonymous session. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 1, + fileIds: ["a"], + selectedFileIds: [], + userId: null, + }), + ); + expect(readWorkbenchSession()).toBeNull(); + }); + + it("drops the previous record when a write fails, rather than leaving it stale", () => { + writeWorkbenchSession({ fileIds: ["old"], selectedFileIds: [] }); + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + + writeWorkbenchSession({ fileIds: ["new"], selectedFileIds: [] }); + setItem.mockRestore(); + + // Better to restore nothing than to restore a workbench the user has moved on from. + expect(readWorkbenchSession()).toBeNull(); + }); + + it("records who the workbench belonged to", () => { + writeWorkbenchSession({ + fileIds: ["a"], + selectedFileIds: [], + userId: "user-1", + }); + expect(readWorkbenchSession()?.userId).toBe("user-1"); + }); + + it("stays gone after sign-out, even though the teardown writes once more", () => { + writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: [] }); + + suspendWorkbenchSession(); + // Signing out unmounts the editor, whose flush writes the workbench one last time - + // with no user attached. Clearing alone let that recreate the record. + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: null, + }); + + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it("keeps the owner when the identity is momentarily unknown", () => { + // A sign-out teardown and a failed /auth/me both write with no user attached. Losing the + // owner here would make the record unrestorable for the person it belongs to. + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: "user-a", + }); + + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: null, + }); + + expect(readWorkbenchSession()?.userId).toBe("user-a"); + }); + + it("still records for a genuinely anonymous session", () => { + // Core has no auth at all, so null is the normal owner there and must keep working. + writeWorkbenchSession({ + fileIds: ["a"], + selectedFileIds: [], + userId: null, + }); + expect(readWorkbenchSession()?.fileIds).toEqual(["a"]); + }); + + it("records again once a new editor session starts", () => { + suspendWorkbenchSession(); + resumeWorkbenchSession(); + writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] }); + expect(readWorkbenchSession()?.fileIds).toEqual(["a"]); + }); + + it("clears on request", () => { + writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] }); + clearWorkbenchSession(); + expect(readWorkbenchSession()).toBeNull(); + }); +}); + +describe("views the restore may reopen", () => { + it("accepts the workbench views a session can land on", () => { + expect(isSeedableView("viewer")).toBe(true); + expect(isSeedableView("fileEditor")).toBe(true); + expect(isSeedableView("pageEditor")).toBe(true); + }); + + it("leaves URL-owned and tool-owned views alone", () => { + // HomePage pins myFiles to /files and bounces it elsewhere; custom views belong to a tool. + expect(isSeedableView("myFiles")).toBe(false); + expect(isSeedableView("custom:compare")).toBe(false); + expect(isSeedableView(undefined)).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts new file mode 100644 index 0000000000..67f6d1abfc --- /dev/null +++ b/frontend/editor/src/core/services/workbenchSession.ts @@ -0,0 +1,173 @@ +// The tab's last editor session (open files, selection, view), so a processor switch or reload +// does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber. + +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; +/** Bumped when the record's shape or meaning changes, so an old one is discarded rather than + * half-read. v2: `userId` became meaningful - v1 records were written without a real owner and + * would otherwise look like they belonged to an anonymous session forever. */ +const SESSION_VERSION = 2; +const RETURN_PATH_KEY = "stirling.workbench.editorReturnPath"; + +// All ids are ORIGINAL file ids - a file's stable identity across versions. +export interface WorkbenchSession { + fileIds: string[]; + selectedFileIds: string[]; + /** Which view was on screen. Absent for a record written before this was tracked. */ + workbench?: string; + activeFileId?: string; + /** Fingerprint of who the workbench belonged to, so the next person in this tab does not + * inherit it. Never the account id itself - see {@link fingerprintOwner}. */ + userId?: string | null; +} + +/** A file's stable identity across versions - what the session records. */ +export function originalIdOf(stub: StirlingFileStub): string { + return stub.originalFileId || (stub.id as string); +} + +export function readWorkbenchSession(): WorkbenchSession | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial & { + v?: number; + }; + if (parsed.v !== SESSION_VERSION) return null; + if (!Array.isArray(parsed.fileIds)) return null; + return { + fileIds: parsed.fileIds.filter((id) => typeof id === "string"), + selectedFileIds: Array.isArray(parsed.selectedFileIds) + ? parsed.selectedFileIds.filter((id) => typeof id === "string") + : [], + workbench: + typeof parsed.workbench === "string" ? parsed.workbench : undefined, + activeFileId: + typeof parsed.activeFileId === "string" + ? parsed.activeFileId + : undefined, + userId: typeof parsed.userId === "string" ? parsed.userId : null, + }; + } catch { + return null; + } +} + +// Sign-out clears the record, but signing out also tears the editor down - and that teardown +// flushes the workbench one last time, recreating what we just deleted (with no user attached). +// So a sign-out has to stop writing too, not merely clear. +let writesSuspended = false; + +/** Sign-out: drop the record and stop recording, so the teardown cannot put it back. */ +export function suspendWorkbenchSession(): void { + writesSuspended = true; + clearWorkbenchSession(); +} + +/** A fresh editor mount is a new session, so recording starts again. */ +export function resumeWorkbenchSession(): void { + writesSuspended = false; +} + +export function writeWorkbenchSession(session: WorkbenchSession): void { + if (writesSuspended) return; + try { + // Never downgrade a known owner to "nobody". Signing out and a failed identity check both + // read as no user, and dropping the owner would either hand the workbench to whoever signs + // in next or lose it for the person it belongs to. Keeping the owner leaves the restore's + // ownership check to decide, which it does with a settled identity. + const owner = session.userId ?? readWorkbenchSession()?.userId ?? null; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ ...session, userId: owner, v: SESSION_VERSION }), + ); + } catch { + // Storage refused (quota, privacy mode). setItem is atomic, so the PREVIOUS record would + // survive and restore an older workbench - drop it, so the failure is "no restore" instead. + clearWorkbenchSession(); + } +} + +/** + * A one-way fingerprint of the signed-in user. Owners are only ever compared, never read back, so + * the account id itself never needs to reach storage. Falls back to a non-cryptographic digest + * where SubtleCrypto is absent (a self-hosted instance served over plain http): the fingerprint + * only has to tell two accounts sharing one tab apart, and the files it gates are reachable from + * My Files regardless, since IndexedDB is per-origin. + */ +export async function fingerprintOwner(userId: string): Promise { + if (globalThis.crypto?.subtle) { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(userId), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .slice(0, 32); + } + let hash = 0x811c9dc5; + for (let i = 0; i < userId.length; i++) { + hash ^= userId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `fnv-${hash.toString(16)}`; +} + +/** Drop the record: on sign-out, and whenever it would otherwise be restored for the wrong person. */ +export function clearWorkbenchSession(): void { + try { + sessionStorage.removeItem(SESSION_KEY); + } catch { + // A record we cannot remove is also one we cannot read. + } +} + +/** Views a restore may seed directly. "myFiles" is URL-owned (HomePage pins it to /files) and a + * custom view belongs to its tool - the editor return path restores those instead. */ +const SEEDABLE_VIEWS = ["viewer", "fileEditor", "pageEditor"]; + +// Raised while a restore is applying its recorded view, so writers that pick a default view from +// whatever is loaded at the time defer to the restore rather than race it. +let applyingRestoredView = false; +let restoreGeneration = 0; + +/** Returns a token for endRestoredView, so a stale release cannot end a newer restore. */ +export function beginRestoredView(): number { + applyingRestoredView = true; + return ++restoreGeneration; +} + +export function endRestoredView(token: number): void { + if (token === restoreGeneration) applyingRestoredView = false; +} + +export function isApplyingRestoredView(): boolean { + return applyingRestoredView; +} + +export function isSeedableView( + view: string | undefined, +): view is "viewer" | "fileEditor" | "pageEditor" { + return view !== undefined && SEEDABLE_VIEWS.includes(view); +} + +export function saveEditorReturnPath(path: string): void { + try { + sessionStorage.setItem(RETURN_PATH_KEY, path); + } catch { + // Best-effort: the switch back just lands on the editor root. + } +} + +/** One-shot: consumed by the switch back so a stale path cannot linger. */ +export function takeEditorReturnPath(): string | null { + try { + const path = sessionStorage.getItem(RETURN_PATH_KEY); + if (path !== null) sessionStorage.removeItem(RETURN_PATH_KEY); + return path; + } catch { + return null; + } +} diff --git a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts index 6107fb5276..c5b24dd6ac 100644 --- a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts @@ -107,6 +107,12 @@ test.describe("engine capabilities", { tag: "@engine-capability" }, () => { await uploadFiles(page, SAMPLE_PDF); + // Dropped before the reload boots, so it cannot reopen the file for us: the eye + // below toggles, and whether the restore runs is a build flag this spec does not own. + await page.addInitScript(() => + sessionStorage.removeItem("stirling.workbench.session"), + ); + // Full reload: FileContext rehydrates from IndexedDB, not from memory. await page.reload({ waitUntil: "domcontentloaded" }); diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts new file mode 100644 index 0000000000..48765392fe --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -0,0 +1,207 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; +import path from "path"; + +const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures"); +const SAMPLES = [ + "compare_sample_a.pdf", + "compare_sample_b.pdf", + "sample.pdf", + "rotated-pages.pdf", + "annotations_out_of_order.pdf", +].map((name) => path.join(FIXTURES_DIR, name)); + +// Read from the running app, not imported: a spec resolves @app/* to a different layer than +// the browser build does, so an imported WORKBENCH_SESSION_RESTORE can disagree with reality. +async function restoreEnabled( + page: import("@playwright/test").Page, +): Promise { + await page.waitForFunction( + () => document.documentElement.dataset.workbenchRestore !== undefined, + null, + { timeout: 20000 }, + ); + return page.evaluate( + () => document.documentElement.dataset.workbenchRestore === "true", + ); +} + +const NO_RESTORE = "this build ships the workbench restore off"; + +// Switching editor -> processor unmounts every editor provider; the session record +// in sessionStorage is what brings the workbench back on return. +test.describe("Workbench survives the editor/processor switch", () => { + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "owner@example.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + test("open files and the library return after a round-trip", async ({ + page, + }) => { + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + // Portal endpoints the processor shell fetches on mount. + for (const [pattern, json] of [ + ["**/api/v1/policies", []], + ["**/api/v1/policies/runs", []], + ["**/api/v1/policies/overview", { pipelines: [] }], + ["**/api/v1/sources", { sources: [] }], + ["**/api/v1/team/my", []], + ] as const) { + await page.route(pattern, (route) => route.fulfill({ json })); + } + + await uploadFiles(page, SAMPLES); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount( + SAMPLES.length, + { timeout: 15000 }, + ); + + // Uploading lands on the file grid, not the viewer - so the return has a + // view it can get wrong (NavigationContext boots to "viewer"). + await expect( + page.getByRole("radio", { name: /Active Files/i }), + ).toBeChecked(); + + // Out through the sidebar footer switch - the real user path. + await page.getByRole("button", { name: "Open PDF Processor" }).click(); + await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); + + // Split the two halves of the feature: if this fails the writer is at fault, + // if it passes but the view below is wrong the seeding is. + expect( + await page.evaluate(() => ({ + session: JSON.parse( + sessionStorage.getItem("stirling.workbench.session") ?? "{}", + ), + returnPath: sessionStorage.getItem( + "stirling.workbench.editorReturnPath", + ), + })), + ).toMatchObject({ + session: { workbench: "fileEditor" }, + returnPath: "/editor", + }); + + // Load the editor cold. Every provider mounts from nothing here, which is + // the loss the restore has to cover on the way back. + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + + await expect(page.locator(".file-sidebar-file-item")).toHaveCount( + SAMPLES.length, + { timeout: 20000 }, + ); + await expect(page.getByText(/compare_sample_a/i).first()).toBeVisible(); + await expect( + page.getByRole("radio", { name: /Active Files/i }), + ).toBeChecked({ timeout: 15000 }); + await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, { + timeout: 15000, + }); + }); +}); + +test.describe("The view survives a reload", () => { + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "o@e.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + const currentView = (page: import("@playwright/test").Page) => + page.evaluate(() => { + const r = Array.from( + document.querySelectorAll("input[type=radio]"), + ).find((x) => x.checked); + return r?.value ?? "none"; + }); + + test("comes back on the same view the user left", async ({ page }) => { + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + await uploadFiles(page, SAMPLES.slice(0, 3)); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 15000, + }); + + // Open a document, so the view under test is the viewer rather than the grid. + await page + .getByRole("button", { name: /Open in Viewer/i }) + .first() + .click({ force: true }); + await expect + .poll(() => currentView(page), { timeout: 10000 }) + .toBe("viewer"); + + // Whatever the workbench settled on is what a reload must reproduce. + const before = await currentView(page); + await page.waitForTimeout(600); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 20000, + }); + await page.waitForTimeout(3000); + expect(await currentView(page)).toBe(before); + }); + + // The conjunction neither neighbour covers: the spec above proves the VIEW comes back, + // engine-capabilities proves stored bytes decode, and nothing proved that the file the + // restore reopened is one whose pixels actually arrive. + test("a file the restore reopened renders its pages", async ({ page }) => { + test.setTimeout(120_000); + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + await uploadFiles(page, SAMPLES.slice(0, 3)); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 15_000, + }); + + await page + .getByRole("button", { name: /Open in Viewer/i }) + .first() + .click({ force: true }); + await expect + .poll(() => currentView(page), { timeout: 10_000 }) + .toBe("viewer"); + // Let the record settle: the writer debounces, so a reload can outrun it. + await page.waitForTimeout(600); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 30_000, + }); + + // A tile that decoded has non-zero naturalWidth. The restore resolves each recorded id + // to its current leaf, so an empty tile here means it reopened something unreadable. + const tile = page + .locator('[data-page-index="0"]') + .first() + .locator('img[src^="blob:"]') + .first(); + await expect(tile).toBeAttached({ timeout: 30_000 }); + await expect + .poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), { + timeout: 30_000, + }) + .toBeGreaterThan(0); + }); +}); diff --git a/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx b/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx new file mode 100644 index 0000000000..2957ac3735 --- /dev/null +++ b/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx @@ -0,0 +1,4 @@ +// Stub: desktop opens OS-launched files on boot; a session restore would collide with that. +export function WorkbenchSessionPersistence() { + return null; +} diff --git a/frontend/editor/src/desktop/extensions/accountLogout.ts b/frontend/editor/src/desktop/extensions/accountLogout.ts index b97b06ec6e..ca75c9c5e9 100644 --- a/frontend/editor/src/desktop/extensions/accountLogout.ts +++ b/frontend/editor/src/desktop/extensions/accountLogout.ts @@ -1,4 +1,5 @@ import { connectionModeService } from "@app/services/connectionModeService"; +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; type SignOutFn = () => Promise; @@ -16,6 +17,10 @@ export function useAccountLogout() { redirectToLogin, }: AccountLogoutDeps): Promise => { try { + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); const currentConfig = await connectionModeService.getCurrentConfig(); diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 8ce7008d67..685fa32722 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { takeEditorReturnPath } from "@app/services/workbenchSession"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -57,7 +58,7 @@ export function Sidebar() { // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) // needs a full page load. const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(EDITOR_BASENAME); + if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); else window.location.href = EDITOR_URL; }; diff --git a/frontend/editor/src/proprietary/auth/spring/UseSession.tsx b/frontend/editor/src/proprietary/auth/spring/UseSession.tsx index 289f2ec895..27a722b2d7 100644 --- a/frontend/editor/src/proprietary/auth/spring/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/spring/UseSession.tsx @@ -12,6 +12,7 @@ import { type AuthUser, type AuthTranslate, } from "@app/auth/types"; +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; /** * Strip the configured base path so route comparisons work under subpath @@ -97,6 +98,11 @@ export function SpringAuthProvider({ const signOut = useCallback(async () => { try { setError(null); + // Signing out is deliberate, unlike an identity check that merely failed: drop the + // workbench record here and stop recording, so the teardown that follows cannot + // write it back for whoever signs in next. + suspendWorkbenchSession(); + const { error } = await springAuth.signOut(); // Always clear the in-memory session: springAuth.signOut() removes the diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx new file mode 100644 index 0000000000..dc8ff6397d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +// A file can re-enter the workbench without being a new upload (My Files reopen, session restore). +// The persisted dispatch record must stop the upload policy (and its billing) firing a second time. + +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ id: string; derivedFromTool?: boolean }>, + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + getStirlingFile: vi.fn(), +})); + +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + }), + useFileContext: () => ({ consumeFiles: vi.fn() }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: vi.fn() }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + security: { + configured: true, + status: "active", + backendId: "backend-security", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: vi.fn().mockResolvedValue(null), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn().mockResolvedValue(true), + }, +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + markDispatched, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + mocks.listPolicyRuns.mockResolvedValue([]); + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.runStoredPolicy.mockResolvedValue("run-0"); + // Completed with no outputs: the run settles without the import machinery. + mocks.getPolicyRun.mockResolvedValue({ + runId: "run-0", + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [], + }); +}); + +describe("upload policies and files re-entering the workbench", () => { + it("does not re-run on a file the policy already ran on", async () => { + markDispatched("security", "already-enforced"); + mocks.workspace = [{ id: "already-enforced" }, { id: "fresh-upload" }]; + + renderHook(() => usePolicyAutoRun()); + + await waitFor(() => expect(mocks.runStoredPolicy).toHaveBeenCalledTimes(1)); + expect(mocks.getStirlingFile).toHaveBeenCalledWith("fresh-upload"); + expect(mocks.getStirlingFile).not.toHaveBeenCalledWith("already-enforced"); + }); + + it("stays silent when every file in the workbench has already been enforced", async () => { + markDispatched("security", "one"); + markDispatched("security", "two"); + mocks.workspace = [{ id: "one" }, { id: "two" }]; + + renderHook(() => usePolicyAutoRun()); + + // Give the dispatch effect a tick to (wrongly) fire before asserting silence. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.runStoredPolicy).not.toHaveBeenCalled(); + }); + + it("still skips a policy's own output, which is not an upload at all", async () => { + mocks.workspace = [{ id: "policy-output", derivedFromTool: true }]; + + renderHook(() => usePolicyAutoRun()); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.runStoredPolicy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/proprietary/constants/featureFlags.ts b/frontend/editor/src/proprietary/constants/featureFlags.ts index e432b34599..84af388005 100644 --- a/frontend/editor/src/proprietary/constants/featureFlags.ts +++ b/frontend/editor/src/proprietary/constants/featureFlags.ts @@ -13,3 +13,6 @@ * Watched Folders implementation to navigate to). */ export const WATCHED_FOLDERS_ENABLED: boolean = false; + +// Refill an empty workbench from the tab's last session (survives the editor/processor switch). +export const WORKBENCH_SESSION_RESTORE: boolean = true; diff --git a/frontend/editor/src/proprietary/extensions/accountLogout.ts b/frontend/editor/src/proprietary/extensions/accountLogout.ts index 6ae83b8e26..8c4d9f15d7 100644 --- a/frontend/editor/src/proprietary/extensions/accountLogout.ts +++ b/frontend/editor/src/proprietary/extensions/accountLogout.ts @@ -1,3 +1,5 @@ +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; + type SignOutFn = () => Promise; interface AccountLogoutDeps { @@ -21,6 +23,10 @@ export function useAccountLogout() { "1", ); } + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); } finally { redirectToLogin(); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx new file mode 100644 index 0000000000..c2a81c53d9 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + requestNavigation: vi.fn(), + portalAccess: true, +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mocks.navigate, + useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }), +})); +vi.mock("@app/auth/context", () => ({ + useAuth: () => ({ portalAccess: mocks.portalAccess }), +})); +vi.mock("@app/contexts/NavigationContext", () => ({ + useNavigationActions: () => ({ + actions: { requestNavigation: mocks.requestNavigation }, + }), +})); + +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { takeEditorReturnPath } from "@app/services/workbenchSession"; + +beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + mocks.portalAccess = true; +}); + +describe("useOtherAppSwitch", () => { + it("offers no switch without portal access", () => { + mocks.portalAccess = false; + const { result } = renderHook(() => useOtherAppSwitch()); + expect(result.current).toBeNull(); + }); + + it("routes the switch through the unsaved-changes guard", () => { + const { result } = renderHook(() => useOtherAppSwitch()); + result.current?.onOpen(); + + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.requestNavigation).toHaveBeenCalledTimes(1); + }); + + it("records where to return to, then navigates to the processor", () => { + const { result } = renderHook(() => useOtherAppSwitch()); + result.current?.onOpen(); + mocks.requestNavigation.mock.calls[0][0](); + + expect(takeEditorReturnPath()).toBe("/compress?mode=fast"); + expect(mocks.navigate).toHaveBeenCalledWith("/processor"); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts index 8bf07b5c2f..dff36a06f4 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -1,6 +1,8 @@ -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/context"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { saveEditorReturnPath } from "@app/services/workbenchSession"; import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; /** @@ -10,6 +12,16 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const { portalAccess } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); + const { actions } = useNavigationActions(); if (!portalAccess) return null; - return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; + return { + app: "processor", + onOpen: () => + // Through the guard, so unsaved edits get the same warning as any other navigation. + actions.requestNavigation(() => { + saveEditorReturnPath(location.pathname + location.search); + navigate(PORTAL_BASENAME); + }), + }; } diff --git a/frontend/editor/src/saas/auth/AuthProvider.test.tsx b/frontend/editor/src/saas/auth/AuthProvider.test.tsx index ffeca5433d..bfd3bef352 100644 --- a/frontend/editor/src/saas/auth/AuthProvider.test.tsx +++ b/frontend/editor/src/saas/auth/AuthProvider.test.tsx @@ -1,6 +1,7 @@ import { act, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Session, User } from "@supabase/supabase-js"; +import { expectConsole } from "@app/tests/failOnConsole"; /** * Request-count tests for {@link AuthProvider}'s data loading. It used to fetch @@ -16,6 +17,7 @@ const createSignedUrl = vi.fn(); const storageFrom = vi.fn((_bucket: string) => ({ createSignedUrl })); const getSession = vi.fn(); const onAuthStateChange = vi.fn(); +const supabaseSignOut = vi.fn(); const unsubscribe = vi.fn(); vi.mock("@app/auth/supabase", () => ({ @@ -26,7 +28,7 @@ vi.mock("@app/auth/supabase", () => ({ refreshSession: vi .fn() .mockResolvedValue({ data: { session: null }, error: null }), - signOut: vi.fn().mockResolvedValue({ error: null }), + signOut: () => supabaseSignOut(), }, rpc: (...args: unknown[]) => rpc(...args), storage: { from: (bucket: string) => storageFrom(bucket) }, @@ -53,6 +55,8 @@ vi.mock("@app/services/userService", () => ({ // Imported after the mocks so the provider picks them up. const { AuthProvider, useAuth } = await import("@app/auth/UseSession"); +const { writeWorkbenchSession, readWorkbenchSession, resumeWorkbenchSession } = + await import("@app/services/workbenchSession"); /** Surfaces `loading` so a test can assert on it rather than on the container. */ function LoadingProbe() { @@ -337,3 +341,55 @@ describe("AuthProvider user-data loading", () => { expect(rpc).toHaveBeenCalledTimes(1); }); }); + +/** + * Signing out suspends workbench recording before the request, so a teardown cannot write the + * record back for whoever signs in next. When the request fails the session stands, and a + * still-signed-in user must not be left silently not recording. + */ +describe("a sign-out that fails", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + resumeWorkbenchSession(); + getSession.mockResolvedValue({ + data: { session: makeSession() }, + error: null, + }); + onAuthStateChange.mockImplementation(() => ({ + data: { subscription: { unsubscribe } }, + })); + rpc.mockResolvedValue({ data: null, error: null }); + getProfilePictureMetadata.mockResolvedValue(null); + syncOAuthAvatar.mockResolvedValue(undefined); + synchronizeUserUpgrade.mockResolvedValue(undefined); + }); + + it("leaves the workbench still being recorded", async () => { + expectConsole.error(/\[Auth Debug\] Sign out error/); + supabaseSignOut.mockResolvedValue({ error: new Error("network down") }); + + let signOut: (() => Promise) | null = null; + function SignOutProbe() { + signOut = useAuth().signOut; + return null; + } + render( + + + , + ); + await waitFor(() => expect(signOut).not.toBeNull()); + + await act(async () => { + await signOut!(); + }); + + writeWorkbenchSession({ + fileIds: ["still-here"], + selectedFileIds: [], + userId: "user-a", + }); + expect(readWorkbenchSession()?.fileIds).toEqual(["still-here"]); + }); +}); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index a1c4db2396..4bd3b99346 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -22,6 +22,10 @@ import { getProviderAvatarUrl, type ProfilePictureMetadata, } from "@app/services/avatarSyncService"; +import { + resumeWorkbenchSession, + suspendWorkbenchSession, +} from "@app/services/workbenchSession"; // Extend Supabase User to include optional username for compatibility export type User = SupabaseUser & { username?: string }; @@ -355,11 +359,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signOut = async () => { try { setError(null); + // Signing out is deliberate, unlike an identity check that merely failed: drop the + // workbench record here and stop recording, so the teardown that follows cannot + // write it back for whoever signs in next. + suspendWorkbenchSession(); + const { error } = await supabase.auth.signOut(); if (error) { console.error("[Auth Debug] Sign out error:", error); setError(error); + // The sign-out did not happen and the session stands, so keep recording: + // otherwise a still-signed-in user silently stops persisting their workbench. + resumeWorkbenchSession(); } else { console.debug("[Auth Debug] Signed out successfully"); setSession(null); @@ -367,6 +379,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (err) { console.error("[Auth Debug] Unexpected error during sign out:", err); setError(err as AuthError); + resumeWorkbenchSession(); } }; diff --git a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts index 824e612bd8..67d0aa7d49 100644 --- a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts @@ -1,6 +1,8 @@ -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { usePortalAccess } from "@app/hooks/usePortalAccess"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { saveEditorReturnPath } from "@app/services/workbenchSession"; import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; /** @@ -11,6 +13,16 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const portalAccess = usePortalAccess(); const navigate = useNavigate(); + const location = useLocation(); + const { actions } = useNavigationActions(); if (!portalAccess) return null; - return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; + return { + app: "processor", + onOpen: () => + // Through the guard, so unsaved edits get the same warning as any other navigation. + actions.requestNavigation(() => { + saveEditorReturnPath(location.pathname + location.search); + navigate(PORTAL_BASENAME); + }), + }; } From 732ef18ae56758e78782446315e7d1b6f31bca4f Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:32:32 +0000 Subject: [PATCH 04/17] feat(account-link): redirect-based connect handshake for self-hosted linking (#7494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links a self-hosted instance to a SaaS team over an ordinary redirect, and leaves the admin's browser holding a Stirling session at the same time. ## The problem A self-hosted server needs a device credential bound to a SaaS team, and the admin's Supabase JWT must never reach the instance backend. Three things ruled out the obvious approaches: - **A customer hostname can never be in Supabase's redirect allow-list**, so the sign-in cannot happen on the instance's own origin. That is why SSO and sign-up did not work for linking at all. - **A device credential identifies a server, not a person.** Every attended portal read (Usage, Billing, Documents, Infrastructure) goes through `getPortalSaasToken()` and needs a *user* session, so a credential-only link left all of them asking for a second sign-in. - **The previous design relayed a JWT** from the browser into the instance, which is the thing we wanted to avoid. That path is deleted here. ## The solution Redirect and nonce, modelled on desktop's `authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser off, accept only a callback carrying that nonce back. Desktop has the OS route the reply; self-hosted has no OS hop, so our own approval page performs it. That is the point — the human half happens on an origin we control. ``` instance SaaS admin's browser | POST connect/request | | | (name, callback, nonce, | | | claim-secret hash) | | |-------------------------->| | | <- requestId + authorizeUrl | | | GET /link?request=... | | |<-------------------------------| | | sign in (SSO works here), | | | see ACCOUNT + ORIGIN, approve | | |------------------------------->| | | 302 callback#nonce+session | | POST connect/claim | | | (requestId, claim secret)| | |-------------------------->| | | <- device credential | | ``` Four properties carry the safety, and each is stated in the code because each is easy to lose in a refactor: - **The redirect target is never caller-supplied.** Validated once at creation, then read back from the stored row, so nothing in the approval page's URL can steer the token elsewhere. - **Approval and minting are separate.** Approval records the team and hands out nothing usable; the credential is minted only on claim, authenticated by a secret that never entered a browser. - **A re-authentication cannot move a server between teams.** The team is pinned at creation from the credential only that instance holds, so an approver from another team gets `WRONG_TEAM` instead of a rebind. - **The approver has to confirm what they are binding.** The page shows the address and the signed-in account, with a way to switch, and a checkbox naming the address gates the approve button. The name the server reports is deliberately not shown: the requester picks it on an unauthenticated endpoint, and its honest value is the hostname already in the address. The session rides the URL fragment, so it stays out of access logs and `Referer`, and is stripped before anything awaits. The claim is row-locked, so one approval mints once. A request lives 30 minutes; a settled one is not offered again, since approving it fails server-side. Signing in mid-flow no longer loses the request. The id is kept on the SaaS origin and resumed after any sign-in, which is what makes creating an account work: the confirmation email opens a new tab, where the `next` parameter is gone. Reading it does not consume it — the request may be open in two tabs — and only a recorded decision retires it. The result lands as a modal over the portal the admin started from, and the portal re-reads its link status so the page behind agrees with the modal. Plaintext `http://` callbacks are accepted rather than refused, because many self-hosted instances legitimately run plain HTTP on a private network; the address carries a warning icon explaining the risk, derived server-side so a requester cannot suppress it. Hard-refusing `http://` to a public IP literal is a reasonable follow-up; a bare hostname can't be classified without a DNS lookup, so the warning stays the general mechanism. ## Configuration Four surfaces. Placeholders below, not values. **SaaS backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | The connect controller and service are `@ConditionalOnProperty` with no default, so without it the endpoints do not exist. | | `system.frontendUrl` | Only when the approval page is not on the API's own origin | Where the approver is sent. Must include the app's base path if it is served under one, or the redirect misses `/link`. | **SaaS frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Its own sign-in. Must be the project the SaaS backend validates tokens against. | | `RUN_SUBPATH` | Only if served under a subpath | Moves the approval page to `//link`, so `system.frontendUrl` has to agree. | **Self-hosted backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to `false`. | | `stirling.billing.account-link.saas-base-url` | Yes | Origin of the SaaS API it links to. Not the SaaS frontend. | | `system.frontendUrl` | Optional | Externally reachable base URL for the callback. Otherwise derived from the request's `Origin`, which is right for ordinary deployments and wrong behind a rewriting proxy. | **Self-hosted frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Accepts the session handed over in the callback fragment. | | `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the SaaS API with the admin's token. Absent, those surfaces stay on the mock. | | `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the portal automatically; without it there is no link UI and no callback route. | Two things worth stating because neither fails loudly: - **Both frontends must use the URL *and* key of the same Supabase project**, and the same one the SaaS backend validates against. A key from one project with a URL from another is accepted by the browser and rejected by Supabase, which surfaces much later as "session expired" on Usage rather than as an error at hand-over. - **The Supabase redirect allow-list must contain the SaaS app's `/auth/callback`**, since a confirmation email returns through it. Entries are matched exactly. - **`system.frontendUrl` is the existing setting for this**, not a new one, so each side reads its own value and there is nothing extra to configure. It also gates share links, so on a stack with storage and sharing already on, setting it here turns those on too. The self-hosted side deliberately does **not** configure where the approval page lives — SaaS answers that in the connect-request reply, being the only party that knows. Also here, because testing this needs two stacks side by side: `linked:staging` / `linked:dev` (which derive `system.frontendUrl` and `RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a per-mode vite `cacheDir` — two dev servers in different modes otherwise re-optimise over one shared dep cache. ## How to test Automated and green: `task frontend:check:all` plus both backend modules. `ConnectRequestServiceTest` covers callback validation, the per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM` and reauth confirming without minting; `ConnectServiceTest` covers callback-resolution precedence including a foreign-origin callback being discarded; `ConnectControllerTest` covers the authorize URL, including the forwarded-header path and only the first hop being trusted; `ConnectCallback.test.tsx` covers the fragment being stripped synchronously and malformed fragments refused; `LinkAccountModal.test.tsx` covers link and reauth hitting different endpoints. Manual walkthrough: 1. `task linked:staging` — added here; brings up a SaaS stack and a self-hosted instance pointed at it, on discovered ports, and prints the four addresses. 2. Open the link-account modal in the self-hosted portal and continue. Expect the SaaS approval page at `/link?request=`. 3. Sign in as a team leader, or create an account and confirm the email. Either way you should come back to the approval page. 4. Tick the acknowledgement and approve. Expect the fragment gone from the address bar immediately, a result modal over the portal, the portal showing linked without a reload, and attended reads (Usage, Billing) working without a second sign-in. 5. Repeat, approving as a member of a different team. Expect a refusal, not a rebind. ## Outstanding - #7415 to be reworked against this design once this lands. - **No SaaS-side UI to disconnect a server.** `GET /account-link/instances` and `POST /account-link/instances/{id}/revoke` are already team-scoped and leader-gated, and the portal has a panel that uses them, but `portal-saas/components/settings/accountLinkSettings.tsx` exports `null` on the reasoning that "SaaS has no account-link concept". That held when linking was a self-hosted admin managing their own instance; here a leader approves a server they may not administer, and has no way to withdraw it. The seam to fill is that one file. Expected to land with the CTA work in #7415. --------- Co-authored-by: James Brunton --- .taskfiles/backend.yml | 65 ++- .taskfiles/frontend.yml | 16 +- Taskfile.yml | 86 ++++ .../src/main/resources/settings.yml.template | 2 +- .../accountlink/AccountLinkClient.java | 206 ++++++--- .../accountlink/AccountLinkController.java | 132 ++++-- .../accountlink/AccountLinkProperties.java | 35 +- .../accountlink/AccountLinkService.java | 26 +- .../accountlink/AccountLinkSyncState.java | 2 +- .../AccountLinkSyncStateRepository.java | 2 +- .../accountlink/ConnectService.java | 276 ++++++++++++ .../proprietary/accountlink/ConnectState.java | 60 +++ .../accountlink/ConnectStateRepository.java | 6 + .../accountlink/DeviceCredential.java | 4 +- .../accountlink/InstanceEntitlementGate.java | 2 +- .../InstanceEntitlementInterceptor.java | 4 +- .../accountlink/MeteredInputSignature.java | 10 +- .../MeteredInputSignatureRepository.java | 2 +- .../proprietary/accountlink/UsageCounter.java | 8 +- .../accountlink/UsageCounterRepository.java | 2 +- .../accountlink/UsageSyncService.java | 4 +- .../proprietary/billing/ContentHasher.java | 6 +- .../accountlink/AccountLinkClientTest.java | 94 ++-- .../AccountLinkControllerTest.java | 73 ++-- .../accountlink/AccountLinkServiceTest.java | 22 +- .../accountlink/ConnectServiceTest.java | 408 ++++++++++++++++++ .../accountlink/AccountLinkController.java | 92 +--- .../saas/accountlink/AccountLinkService.java | 35 +- .../saas/accountlink/ConnectController.java | 277 ++++++++++++ .../saas/accountlink/ConnectRequest.java | 103 +++++ .../ConnectRequestCleanupService.java | 47 ++ .../accountlink/ConnectRequestRepository.java | 28 ++ .../accountlink/ConnectRequestService.java | 391 +++++++++++++++++ .../DeviceCredentialAuthenticationFilter.java | 2 +- .../saas/accountlink/InstanceController.java | 6 +- .../saas/accountlink/LeaderTeamResolver.java | 68 +++ .../saas/accountlink/LinkedInstance.java | 2 +- .../LinkedInstanceAuthenticationToken.java | 2 +- .../software/saas/accountlink/Origins.java | 22 + .../saas/config/SaasSchemaOwnership.java | 2 + .../instance/InstanceUsageIngestService.java | 12 +- .../saas/payg/instance/PaygInstanceUsage.java | 6 +- .../saas/security/SupabaseSecurityConfig.java | 55 ++- .../saas/service/SaasTeamService.java | 4 +- .../AccountLinkControllerTest.java | 46 +- .../accountlink/ConnectControllerTest.java | 129 ++++++ .../ConnectRequestServiceTest.java | 358 +++++++++++++++ .../SupabaseSecurityConfigMoreTest.java | 54 ++- .../public/locales/en-US/translation.toml | 82 +++- .../editor/src/portal/PortalProviders.tsx | 39 +- frontend/editor/src/portal/api/link.test.ts | 26 +- frontend/editor/src/portal/api/link.ts | 128 +++--- .../editor/src/portal/auth/saasSupabase.ts | 11 +- .../account-link/ConnectCallbackHost.tsx | 184 ++++++++ .../account-link/ConnectCallbackView.tsx | 162 +++++++ .../account-link/LinkAccountCard.stories.tsx | 3 +- .../account-link/LinkAccountModal.css | 26 ++ .../account-link/LinkAccountModal.stories.tsx | 9 +- .../account-link/LinkAccountModal.test.tsx | 128 ++++++ .../account-link/LinkAccountModal.tsx | 162 +++---- .../portal/contexts/AccountLinkContext.tsx | 10 +- .../src/portal/contexts/LinkContext.tsx | 18 +- .../src/portal/hooks/useAccountLink.test.tsx | 75 ---- .../editor/src/portal/hooks/useAccountLink.ts | 112 +---- .../editor/src/portal/mocks/handlers/link.ts | 51 ++- frontend/editor/src/portal/mocks/link.ts | 2 +- .../src/portal/views/ConnectCallback.css | 32 ++ .../src/portal/views/ConnectCallback.test.tsx | 175 ++++++++ .../src/portal/views/ConnectCallback.tsx | 41 ++ .../routes/adminRouteExtensions.tsx | 27 +- frontend/editor/src/saas/App.tsx | 7 + .../editor/src/saas/routes/AuthCallback.tsx | 17 +- .../editor/src/saas/routes/ConnectApprove.tsx | 172 ++++++++ .../src/saas/routes/ConnectApproveView.tsx | 182 ++++++++ .../src/saas/routes/ResumePendingConnect.tsx | 38 ++ frontend/editor/src/saas/routes/connect.css | 93 ++++ .../editor/src/saas/routes/pendingConnect.ts | 61 +++ frontend/editor/vite.config.ts | 10 + 78 files changed, 4524 insertions(+), 853 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java create mode 100644 frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx create mode 100644 frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx create mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.css create mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx delete mode 100644 frontend/editor/src/portal/hooks/useAccountLink.test.tsx create mode 100644 frontend/editor/src/portal/views/ConnectCallback.css create mode 100644 frontend/editor/src/portal/views/ConnectCallback.test.tsx create mode 100644 frontend/editor/src/portal/views/ConnectCallback.tsx create mode 100644 frontend/editor/src/saas/routes/ConnectApprove.tsx create mode 100644 frontend/editor/src/saas/routes/ConnectApproveView.tsx create mode 100644 frontend/editor/src/saas/routes/ResumePendingConnect.tsx create mode 100644 frontend/editor/src/saas/routes/connect.css create mode 100644 frontend/editor/src/saas/routes/pendingConnect.ts diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 08a12b9535..3e43321937 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -40,12 +40,15 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' + # Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing + # and cannot blank the committed default. + ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: @@ -84,6 +87,8 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + APP_BASE_URL: '{{.APP_BASE_URL}}' + BASE_PATH: '{{.BASE_PATH}}' staging:saas: desc: "Start SaaS backend against the shared v3 staging project" @@ -95,10 +100,47 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + APP_BASE_URL: '{{.APP_BASE_URL}}' + BASE_PATH: '{{.BASE_PATH}}' + + dev:linked: + desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)" + ignore_error: true + vars: + PORT: '{{.PORT | default "8080"}}' + SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}' + cmds: + - 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"' + # The two backends run different STIRLING_FLAVOURs, which are different Gradle + # project graphs sharing one build/ tree. Waiting avoids overlapping builds; it + # does not make the sharing safe, so avoid rebuilding one while the other runs. + - cmd: | + n=0 + while [ "$n" -lt 150 ]; do + if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then + echo ">> SaaS backend is up, starting self-hosted" + break + fi + n=$((n + 1)) + {{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}} + done + if [ "$n" -ge 150 ]; then + echo ">> SaaS backend never answered; starting anyway" + fi + - task: dev:proprietary + vars: + PORT: '{{.PORT}}' + ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}' _run:saas: internal: true - dotenv: ['app/.env.saas.local', 'app/.env.saas'] + # The frontend files are here only for RUN_SUBPATH, which the authorize URL needs. + # Last, because dotenv is set-if-absent: app/* still decides everything else. + dotenv: + - 'app/.env.saas.local' + - 'app/.env.saas' + - 'frontend/editor/.env.saas.local' + - 'frontend/editor/.env.saas' ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' @@ -111,12 +153,29 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' + # Empty is the same as unset: the property defaults to empty and is blank-checked. + APP_BASE_URL: '{{.APP_BASE_URL | default ""}}' + # Relocates configs/pipeline/logs, for a second backend in the same directory. + # Empty is the same as unset: the reader blank-checks it. + BASE_PATH: '{{.BASE_PATH | default ""}}' env: SERVER_PORT: '{{.PORT}}' STIRLING_FLAVOR: saas + STIRLING_BASE_PATH: '{{.BASE_PATH}}' AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + # Appends RUN_SUBPATH: the approval page is at /link, so a subpath build + # serves it at /app/link. An explicit value still wins. + SYSTEM_FRONTENDURL: + sh: | + if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then + echo "${SYSTEM_FRONTENDURL}" + elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then + echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}" + else + echo "{{.APP_BASE_URL}}" + fi cmds: # PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile # against SAAS_DB_* (production). diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 844306824d..7867315037 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -121,17 +121,17 @@ tasks: sh: | case "${SAAS_ENV:-dev}" in staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;; - *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;; + *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;; esac echo "https://${ref}.supabase.co" VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sh: | case "${SAAS_ENV:-dev}" in staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; - *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;; esac cmds: - - 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"' + - 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"' - npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}} dev: @@ -173,6 +173,16 @@ tasks: OPEN: '{{.OPEN}}' SAAS_ENV: '{{.SAAS_ENV}}' + staging:saas: + desc: "Start frontend dev server against the shared v3 staging project" + cmds: + - task: dev:saas + vars: + SAAS_ENV: staging + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + OPEN: '{{.OPEN}}' + dev:desktop: desc: "Start frontend dev server in desktop mode" deps: diff --git a/Taskfile.yml b/Taskfile.yml index 92dcdcc742..e364242f1f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -121,6 +121,92 @@ tasks: cmds: - task: dev:_all + # No engine: linking never calls it. + linked:staging: + desc: "SaaS on the shared v3 project + a self-hosted instance linked to it" + cmds: + - task: linked:_all + vars: { SAAS_ENV: staging } + + linked:dev: + desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it" + cmds: + - task: linked:_all + vars: { SAAS_ENV: dev } + + linked:_all: + internal: true + vars: + SAAS_ENV: '{{.SAAS_ENV | default "staging"}}' + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}' + SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}' + APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}' + deps: + # APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not + # by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not + # race the self-hosted one, which keeps ./configs and its existing database. + - task: 'backend:{{.SAAS_ENV}}:saas' + vars: + PORT: '{{.SAAS_BACKEND_PORT}}' + APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}' + BASE_PATH: 'tmp/linked-saas' + - task: frontend:dev:saas + vars: + PORT: '{{.SAAS_FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}' + SAAS_ENV: '{{.SAAS_ENV}}' + - task: backend:dev:linked + vars: + PORT: '{{.APP_BACKEND_PORT}}' + SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}' + - task: frontend:dev:proprietary + vars: + PORT: '{{.APP_FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}' + OPEN: "true" + - task: linked:_ready + vars: + SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}' + SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}' + APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}' + APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}' + + # Waits for all four to answer, then prints where they landed. + linked:_ready: + internal: true + cmds: + - cmd: | + n=0 + ok=0 + while [ "$n" -lt 150 ]; do + ok=1 + for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \ + "http://localhost:{{.SAAS_FRONTEND_PORT}}" \ + "http://localhost:{{.APP_BACKEND_PORT}}" \ + "http://localhost:{{.APP_FRONTEND_PORT}}"; do + # Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23. + curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0 + done + if [ "$ok" = 1 ]; then break; fi + n=$((n + 1)) + # `sleep` is a binary, not a builtin, and Windows has none. + {{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}} + done + echo "" + if [ "$ok" = 1 ]; then + echo ">> all four answering" + else + echo ">> still waiting on one or more after 5 minutes; addresses below anyway" + fi + echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor" + echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}" + echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}" + echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}" + echo "" + dev:_all: internal: true vars: diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index fdfe40b352..ecf7ea8538 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -186,7 +186,7 @@ system: maxDPI: 500 # Maximum allowed DPI for PDF to image conversion corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down. backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. - frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. + frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake. enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured. mobileScannerSettings: diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index bdd9df10a8..558341745d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode; /** * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode * A"). - * - *

Calls: - * - *

    - *
  • {@link #register} — relays the admin's short-lived Supabase JWT to {@code POST - * /api/v1/account-link/register}; the SaaS side mints + returns a device credential. - *
  • {@link #fetchEntitlement} — authenticates with the stored device credential against {@code - * GET /api/v1/instance/entitlement}; what the local gate consults. - *
  • {@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports - * cumulative units and returns the refreshed entitlement. - *
  • {@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST - * /api/v1/instance/revoke-self}). - *
- * - *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see - * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ @Slf4j @Service @@ -72,13 +56,7 @@ public class AccountLinkClient { this.httpClient = httpClient; } - /** The device credential a successful {@link #register} returns. */ - public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {} - - /** - * A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can - * map auth failures (401/403) through rather than masking everything as a 502. - */ + /** A non-2xx reply from the SaaS account-link API. */ public static class UpstreamException extends IOException { private final int status; @@ -92,11 +70,7 @@ public class AccountLinkClient { } } - /** - * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a - * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on - * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch. - */ + /** Authoritative deny (401/403) — the device credential is revoked or invalid. */ public static final class RevokedException extends RuntimeException { private final int status; @@ -110,46 +84,142 @@ public class AccountLinkClient { } } + /** What the SaaS side hands back when it records a connect handshake. */ + public record ConnectRequestResult( + String requestId, int expiresInSeconds, String authorizeUrl) {} + + public enum ConnectClaimOutcome { + /** Approved and collected; the credential fields are populated. */ + GRANTED, + /** A re-authentication was approved. */ + CONFIRMED, + /** No human decision yet. */ + PENDING, + /** Declined, expired or already used. */ + REJECTED, + /** SaaS unreachable or erroring. */ + UNAVAILABLE + } + + public record ConnectClaimResult( + ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) { + static ConnectClaimResult of(ConnectClaimOutcome outcome) { + return new ConnectClaimResult(outcome, null, null, null); + } + } + + /** Opens a connect handshake. */ + public ConnectRequestResult connectRequest( + String name, String callbackUrl, String nonce, String claimSecret) throws IOException { + return connectRequest(name, callbackUrl, nonce, claimSecret, null); + } + /** - * Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted - * credential. - * - * @throws IOException on transport failure or a non-2xx response (caller surfaces to the - * admin). + * As {@link #connectRequest}, but presenting an existing device credential so the SaaS side + * treats this as a re-authentication and pins the handshake to the team we already belong to. */ - public RegisterResult register(String supabaseJwt, String instanceName) throws IOException { - String body = - instanceName == null || instanceName.isBlank() - ? "{}" - : "{\"name\":" + mapper.writeValueAsString(instanceName) + "}"; - HttpRequest request = + public ConnectRequestResult connectRequest( + String name, + String callbackUrl, + String nonce, + String claimSecret, + DeviceCredential credential) + throws IOException { + ObjectNode root = mapper.createObjectNode(); + if (name != null && !name.isBlank()) { + root.put("name", name); + } + root.put("callbackUrl", callbackUrl); + root.put("nonce", nonce); + root.put("claimSecret", claimSecret); + + HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(uri("/api/v1/account-link/register")) - .header("Authorization", "Bearer " + supabaseJwt) + .uri(uri("/api/v1/account-link/connect/request")) .header("Content-Type", "application/json") .header("Accept", "application/json") .timeout(timeout()) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root))); + if (credential != null) { + builder.header(HEADER_DEVICE_ID, credential.getDeviceId()) + .header(HEADER_DEVICE_SECRET, credential.getDeviceSecret()); + } - HttpResponse response = send(request); + HttpResponse response = send(builder.build()); if (response.statusCode() / 100 != 2) { throw new UpstreamException(response.statusCode(), response.body()); } - JsonNode root = mapper.readTree(response.body()); - String deviceId = text(root, "deviceId"); - String deviceSecret = text(root, "deviceSecret"); - if (deviceId == null || deviceSecret == null) { - throw new IOException("SaaS register response missing deviceId/deviceSecret"); + JsonNode body = mapper.readTree(response.body()); + String requestId = text(body, "requestId"); + if (requestId == null) { + throw new IOException("SaaS connect response missing requestId"); + } + String authorizeUrl = text(body, "authorizeUrl"); + if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) { + throw new IOException("SaaS connect response carried no usable authorizeUrl"); + } + return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl); + } + + /** + * Collects the device credential for an approved handshake, proving possession of the claim + * secret. + */ + public ConnectClaimResult connectClaim(String requestId, String claimSecret) { + HttpResponse response; + try { + ObjectNode root = mapper.createObjectNode(); + root.put("requestId", requestId); + root.put("claimSecret", claimSecret); + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/account-link/connect/claim")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST( + HttpRequest.BodyPublishers.ofString( + mapper.writeValueAsString(root))) + .build(); + response = send(request); + } catch (Exception e) { + log.debug("Connect claim failed (transport): {}", e.getMessage()); + return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE); + } + int status = response.statusCode(); + if (status == 202) { + return ConnectClaimResult.of(ConnectClaimOutcome.PENDING); + } + if (status >= 500 && status <= 599) { + return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE); + } + if (status < 200 || status > 299) { + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); + } + try { + JsonNode body = mapper.readTree(response.body()); + Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null; + // A re-authentication says so explicitly and carries no credential, so an absent + // credential is only an error when we were expecting one. + if ("confirmed".equals(text(body, "status"))) { + return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId); + } + String deviceId = text(body, "deviceId"); + String deviceSecret = text(body, "deviceSecret"); + if (deviceId == null || deviceSecret == null) { + log.warn("Connect claim succeeded but the reply carried no credential"); + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); + } + return new ConnectClaimResult( + ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId); + } catch (RuntimeException e) { + log.debug("Connect claim parse failed: {}", e.getMessage()); + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); } - Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null; - return new RegisterResult(deviceId, deviceSecret, teamId); } /** * Revokes this instance's own credential on the SaaS side, authenticated by that credential. - * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local - * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS. */ public boolean revokeSelf(String deviceId, String deviceSecret) { try { @@ -174,17 +244,7 @@ public class AccountLinkClient { } } - /** - * Fetches the current entitlement using the stored device credential. Three outcomes: - * - *

    - *
  • 2xx → the parsed snapshot. - *
  • 401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential); - * the caller must BLOCK, not fail open. - *
  • transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null} - * ("unknown" — the caller fails open). - *
- */ + /** Fetches the current entitlement using the stored device credential. */ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) { HttpResponse response; try { @@ -224,9 +284,6 @@ public class AccountLinkClient { /** * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes. - * SaaS bills the delta against its last-seen cumulative, so resending the same totals is - * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must - * not advance its last-synced markers so the usage retries next sync. */ public InstanceEntitlement reportUsage( String deviceId, @@ -360,4 +417,19 @@ public class AccountLinkClient { private static String text(JsonNode node, String field) { return node.hasNonNull(field) ? node.get(field).asText() : null; } + + /** Absolute http(s) with a host. */ + static boolean isAbsoluteHttpUrl(String candidate) { + try { + URI uri = URI.create(candidate.strip()); + String scheme = uri.getScheme(); + return uri.isAbsolute() + && scheme != null + && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null + && !uri.getHost().isBlank(); + } catch (IllegalArgumentException e) { + return false; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index 52af366df4..b1826b7cca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; + import lombok.extern.slf4j.Slf4j; -/** - * Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A"). - * - *

The portal (served from this same origin, admin authenticated by the existing self-hosted - * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS - * backend, which mints + returns a device credential we store locally. {@code GET /status} backs - * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds - * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now" - * / test aid). - * - *

Admin-only, {@code @Profile("!saas")}, gated behind {@code - * stirling.billing.account-link.enabled} — off → bean absent → 404. - */ +/** Same-origin account-link surface on the self-hosted instance (combined billing). */ @Slf4j @Hidden @RestController @@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j; public class AccountLinkController { private final AccountLinkService service; + private final ConnectService connectService; private final LocalUsageService localUsageService; // Present only when metering is on (its own flag); absent → /sync-now reports 409. private final ObjectProvider syncServiceProvider; public AccountLinkController( AccountLinkService service, + ConnectService connectService, LocalUsageService localUsageService, ObjectProvider syncServiceProvider) { this.service = service; + this.connectService = connectService; this.localUsageService = localUsageService; this.syncServiceProvider = syncServiceProvider; } - /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ - public record LinkRequest(String supabaseJwt, String name) {} + /** {@code callbackUrl} is the portal telling us where its own callback route lives. */ + public record ConnectStartRequest(String name, String callbackUrl) {} - @PostMapping("/link") - public ResponseEntity link(@RequestBody LinkRequest req) { - if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) { - return ResponseEntity.badRequest() - .body(java.util.Map.of("error", "supabaseJwt is required")); - } + /** {@code nonce} comes from the callback fragment the approval page redirected to. */ + public record ConnectCompleteRequest(String nonce) {} + + /** + * Opens a browser-mediated link handshake and returns the approval URL to send the admin to. + */ + @PostMapping("/connect/start") + public ResponseEntity connectStart( + @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) { try { - return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name())); + return ResponseEntity.ok( + connectService.start(req != null ? req.name() : null, callbackHint(req, http))); } catch (AccountLinkClient.UpstreamException e) { - // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so - // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the - // raw upstream body back to the browser. - HttpStatus status = - e.status() == HttpStatus.UNAUTHORIZED.value() - || e.status() == HttpStatus.FORBIDDEN.value() - ? HttpStatus.valueOf(e.status()) - : HttpStatus.BAD_GATEWAY; - log.warn("Account-link register rejected upstream: HTTP {}", e.status()); - return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED")); - } catch (IOException e) { - // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the - // configured SaaS host/IP. Log it server-side; return the same opaque body the - // UpstreamException branch does. - log.warn("Account-link failed (transport): {}", e.getMessage()); + log.warn("Account-link connect rejected upstream: HTTP {}", e.status()); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(java.util.Map.of("error", "LINK_FAILED")); + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } catch (IOException e) { + // Same reasoning as /link: a transport message can carry the configured SaaS host. + log.warn("Account-link connect failed (transport): {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); } } + /** Re-establishes the admin's SaaS session for a server that is already linked. */ + @PostMapping("/connect/reauth") + public ResponseEntity connectReauth( + @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) { + try { + return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http))); + } catch (AccountLinkClient.UpstreamException e) { + log.warn("Account-link reauth rejected upstream: HTTP {}", e.status()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } catch (IOException e) { + log.warn("Account-link reauth failed: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } + } + + /** Called by the callback page with the nonce it found in the fragment. */ + @PostMapping("/connect/complete") + public ResponseEntity connectComplete( + @RequestBody(required = false) ConnectCompleteRequest req) { + return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null)); + } + + /** Everything we know about where the admin's browser is, for the callback. */ + private static ConnectService.CallbackHint callbackHint( + ConnectStartRequest req, HttpServletRequest http) { + return new ConnectService.CallbackHint( + req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http)); + } + + /** + * This instance's base URL as the browser reached it, including any context path so a subpath + * deployment builds a callback that actually resolves. + */ + private static String baseUrlOf(HttpServletRequest request) { + String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto")); + String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host")); + String scheme = forwardedProto != null ? forwardedProto : request.getScheme(); + String hostPort; + if (forwardedHost != null) { + hostPort = forwardedHost; + } else { + int port = request.getServerPort(); + boolean defaultPort = + ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port; + } + String context = request.getContextPath() == null ? "" : request.getContextPath(); + return scheme + "://" + hostPort + context; + } + + private static String firstHop(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String first = headerValue.split(",")[0].strip(); + return first.isEmpty() ? null : first; + } + @GetMapping("/status") public ResponseEntity status() { return ResponseEntity.ok(service.status()); @@ -106,12 +155,7 @@ public class AccountLinkController { return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); } - /** - * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin - * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: - * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; - * {@code 409} when metering is off (the sync bean is absent). - */ + /** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */ @PostMapping("/sync-now") public ResponseEntity syncNow() { UsageSyncService sync = syncServiceProvider.getIfAvailable(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index 6d1f1fb151..619aa10e86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -8,29 +8,17 @@ import org.springframework.stereotype.Component; import lombok.Getter; import lombok.Setter; -/** - * Self-hosted side of combined-billing "Mode A" (connected self-hosted). - * - *

Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag - * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional - * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is - * off by default and dark — when off nothing gates and the link endpoints 404. - */ +/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */ @Getter @Setter @Component @ConfigurationProperties(prefix = "stirling.billing.account-link") public class AccountLinkProperties { - /** Master switch. When {@code false} (default) the feature is fully inert. */ + /** Master switch. */ private boolean enabled = false; - /** - * Base URL of the SaaS backend this instance links to (register + entitlement live there). - * - *

STUB: defaults to the public cloud host; an operator overrides it for staging. There is no - * existing SaaS-base-url property in the self-hosted profile, so this is introduced here. - */ + /** Base URL of the SaaS backend this instance links to (register + entitlement live there). */ private String saasBaseUrl = "https://stirling.com/app"; /** Cached entitlement is reused for this long before a refresh is attempted. */ @@ -39,20 +27,18 @@ public class AccountLinkProperties { /** Connect/read timeout for the outbound SaaS calls. */ private int requestTimeoutSeconds = 10; - /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ + /** Phase 2 usage metering + daily sync. */ private final Metering metering = new Metering(); /** - * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be - * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap - * enforcement. Both default off; metering requires the master flag too. This is the production - * safety key — flipping it on is what actually bills linked instances. + * Separate from {@link #enabled} so linking can be exercised without billing anything. Both + * default off, and metering needs the master flag as well. */ @Getter @Setter public static class Metering { - /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */ + /** Turns on usage metering, the daily sync, and cap enforcement. */ private boolean enabled = false; /** @@ -65,12 +51,7 @@ public class AccountLinkProperties { */ private int graceDays = 3; - /** - * Dedup window for identical input sets. A re-run of the same inputs within this window is - * treated as workflow chaining and not re-charged; the same inputs run again after it are - * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op - * costs the same on the instance and in the cloud. - */ + /** Dedup window for identical input sets. */ private Duration workflowWindow = Duration.ofMinutes(5); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java index 1bb27d9cd6..e1283d83ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java @@ -1,6 +1,5 @@ package stirling.software.proprietary.accountlink; -import java.io.IOException; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -9,13 +8,7 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; -/** - * Linking orchestrator (self-hosted side of combined-billing "Mode A"). - * - *

{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase - * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest. - * The credential — not the JWT — authenticates all later unattended entitlement calls. - */ +/** Linking orchestrator (self-hosted side of combined billing). */ @Slf4j @Service @Profile("!saas") @@ -38,24 +31,9 @@ public class AccountLinkService { /** Status of this instance's link, for the portal's "Account link" card. */ public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {} - /** - * Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the - * credential. - * - * @throws IOException if the SaaS register call fails (surfaced to the admin as a link error). - */ - public LinkStatus link(String supabaseJwt, String instanceName) throws IOException { - AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName); - credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId()); - entitlementCache.invalidate(); - log.info("Account-link: instance linked to team {}", result.teamId()); - return status(); - } - /** * Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code - * revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear - * still proceeds (admin's intent must win); the orphan row can be revoked from the portal. + * revoked_at} set), then clears locally regardless. */ public void unlink() { credentialStore diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java index fbac6a8603..2715b4743b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -12,7 +12,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A"). + * Singleton row holding this instance's daily-sync bookkeeping (combined billing). * *

{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java index 15b5e3842d..d0cdf36f90 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink; import org.springframework.data.jpa.repository.JpaRepository; -/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ +/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */ public interface AccountLinkSyncStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java new file mode 100644 index 0000000000..978d6d494e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java @@ -0,0 +1,276 @@ +package stirling.software.proprietary.accountlink; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** Browser-mediated account linking, instance side. */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectService { + + /** Frontend route that consumes the callback fragment. */ + static final String CALLBACK_PATH = "/account-link/callback"; + + private static final int SECRET_BYTES = 32; + + private final AccountLinkClient client; + private final ConnectStateRepository stateRepo; + private final DeviceCredentialStore credentialStore; + private final EntitlementCache entitlementCache; + private final ApplicationProperties applicationProperties; + private final SecureRandom random = new SecureRandom(); + + public ConnectService( + AccountLinkClient client, + ConnectStateRepository stateRepo, + DeviceCredentialStore credentialStore, + EntitlementCache entitlementCache, + ApplicationProperties applicationProperties) { + this.client = client; + this.stateRepo = stateRepo; + this.credentialStore = credentialStore; + this.entitlementCache = entitlementCache; + this.applicationProperties = applicationProperties; + } + + public enum Phase { + /** Nothing in flight and not linked. */ + NONE, + /** A handshake is open, waiting for a leader to approve it on the SaaS site. */ + PENDING, + /** Linked. */ + LINKED, + /** The handshake outlived its window; start a new one. */ + EXPIRED, + /** Declined or already used; start a new one. */ + REJECTED, + /** SaaS could not be reached; the handshake is still valid and can be retried. */ + UNAVAILABLE + } + + /** What the portal renders. */ + public record ConnectStatus( + Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) { + static ConnectStatus of(Phase phase) { + return new ConnectStatus(phase, null, null, null); + } + } + + /** Everything we know about where the admin's browser actually is, in decreasing authority. */ + public record CallbackHint( + String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {} + + /** Opens a handshake and returns where to send the admin. */ + @Transactional + public ConnectStatus start(String name, CallbackHint hint) throws IOException { + if (credentialStore.isLinked()) { + return status(); + } + return open(name, hint, null); + } + + /** + * Opens a handshake that only re-establishes the admin's browser session, for an instance that + * is already linked. + */ + @Transactional + public ConnectStatus startReauth(CallbackHint hint) throws IOException { + DeviceCredential credential = + credentialStore + .get() + .orElseThrow( + () -> + new IOException( + "This server is not linked, so there is no session" + + " to re-establish")); + return open(credential.getDeviceId(), hint, credential); + } + + private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential) + throws IOException { + String callbackUrl = resolveCallbackUrl(hint); + if (callbackUrl == null) { + throw new IOException( + "Cannot determine where to send the admin back to; set system.frontendUrl"); + } + String nonce = randomSecret(); + String claimSecret = randomSecret(); + + AccountLinkClient.ConnectRequestResult created = + client.connectRequest(name, callbackUrl, nonce, claimSecret, credential); + + LocalDateTime now = LocalDateTime.now(); + ConnectState state = new ConnectState(); + state.setId(ConnectState.SINGLETON_ID); + state.setRequestId(created.requestId()); + state.setNonce(nonce); + state.setClaimSecret(claimSecret); + state.setCallbackUrl(callbackUrl); + state.setAuthorizeUrl(created.authorizeUrl()); + state.setCreatedAt(now); + state.setExpiresAt( + now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900)); + stateRepo.save(state); + + log.info("Account-link connect: handshake {} opened", created.requestId()); + return pendingStatus(state, now); + } + + /** Finishes a handshake from the callback the approval page redirected to. */ + @Transactional + public ConnectStatus complete(String nonce) { + Optional found = stateRepo.findById(ConnectState.SINGLETON_ID); + if (found.isEmpty()) { + // Already finished (a double-submitted callback) or never started. + return status(); + } + ConnectState state = found.get(); + if (state.isExpired(LocalDateTime.now())) { + stateRepo.delete(state); + return ConnectStatus.of(Phase.EXPIRED); + } + if (nonce == null || !nonceMatches(nonce, state.getNonce())) { + log.warn( + "Account-link connect: callback for handshake {} had a bad nonce", + state.getRequestId()); + return ConnectStatus.of(Phase.REJECTED); + } + + AccountLinkClient.ConnectClaimResult claim = + client.connectClaim(state.getRequestId(), state.getClaimSecret()); + return switch (claim.outcome()) { + case GRANTED -> { + credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId()); + entitlementCache.invalidate(); + stateRepo.delete(state); + log.info("Account-link connect: linked to team {}", claim.teamId()); + yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId()); + } + case CONFIRMED -> { + stateRepo.delete(state); + log.info( + "Account-link connect: session re-established for team {}", claim.teamId()); + yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId()); + } + case PENDING -> + // The admin reached the callback before the approval committed. The row stays, + // so a retry finishes it. + ConnectStatus.of(Phase.PENDING); + case REJECTED -> { + stateRepo.delete(state); + yield ConnectStatus.of(Phase.REJECTED); + } + case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE); + }; + } + + @Transactional(readOnly = true) + public ConnectStatus status() { + Optional credential = credentialStore.get(); + if (credential.isPresent()) { + return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId()); + } + Optional state = stateRepo.findById(ConnectState.SINGLETON_ID); + if (state.isEmpty()) { + return ConnectStatus.of(Phase.NONE); + } + LocalDateTime now = LocalDateTime.now(); + if (state.get().isExpired(now)) { + return ConnectStatus.of(Phase.EXPIRED); + } + return pendingStatus(state.get(), now); + } + + private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) { + long remaining = Duration.between(now, state.getExpiresAt()).toSeconds(); + return new ConnectStatus( + Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null); + } + + /** Decides the callback, preferring knowledge over inference. */ + String resolveCallbackUrl(CallbackHint hint) { + String configured = applicationProperties.getSystem().getFrontendUrl(); + if (configured != null && !configured.isBlank()) { + return trimTrailingSlash(configured.strip()) + CALLBACK_PATH; + } + String browserOrigin = originOf(hint.browserOrigin()); + if (browserOrigin != null) { + String requested = hint.requestedCallbackUrl(); + if (requested != null && browserOrigin.equals(originOf(requested))) { + return requested.strip(); + } + return browserOrigin + CALLBACK_PATH; + } + return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank() + ? null + : trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH; + } + + /** Scheme, host and port of an absolute http(s) URL; null if it is not one. */ + private static String originOf(String candidate) { + if (candidate == null || candidate.isBlank()) { + return null; + } + URI uri; + try { + uri = new URI(candidate.strip()); + } catch (URISyntaxException e) { + return null; + } + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return null; + } + int port = uri.getPort(); + boolean defaultPort = + port == -1 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return defaultPort + ? scheme + "://" + uri.getHost() + : scheme + "://" + uri.getHost() + ":" + port; + } + + private static String trimTrailingSlash(String value) { + return value.replaceAll("/+$", ""); + } + + private String randomSecret() { + byte[] buf = new byte[SECRET_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** Constant-time so a caller cannot probe the nonce a character at a time. */ + private static boolean nonceMatches(String candidate, String expected) { + if (expected == null) { + return false; + } + return MessageDigest.isEqual( + candidate.getBytes(StandardCharsets.UTF_8), + expected.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java new file mode 100644 index 0000000000..c0dcea032e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java @@ -0,0 +1,60 @@ +package stirling.software.proprietary.accountlink; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** The one in-flight "connect this server" handshake, instance side. */ +@Entity +@Table(name = "account_link_connect_state") +@NoArgsConstructor +@Getter +@Setter +public class ConnectState implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final Long SINGLETON_ID = 1L; + + @Id + @Column(name = "id") + private Long id = SINGLETON_ID; + + /** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */ + @Column(name = "request_id", nullable = false, length = 64) + private String requestId; + + /** Correlator we minted. */ + @Column(name = "nonce", nullable = false, length = 128) + private String nonce; + + /** Secret we minted and sent to SaaS server to server. */ + @Column(name = "claim_secret", nullable = false, length = 128) + private String claimSecret; + + /** Where we asked the approval page to send the admin back to. */ + @Column(name = "callback_url", nullable = false, length = 2048) + private String callbackUrl; + + /** The approval URL handed to the browser, so a reload can offer it again. */ + @Column(name = "authorize_url", nullable = false, length = 2048) + private String authorizeUrl; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && expiresAt.isBefore(now); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java new file mode 100644 index 0000000000..995dfccde1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Data access for the singleton {@link ConnectState} row. */ +public interface ConnectStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java index 4625572310..7da486b741 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java @@ -13,8 +13,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * The device credential this self-hosted instance received when it linked a SaaS account - * (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team. + * The device credential this self-hosted instance received when it linked a SaaS account (combined + * billing). Singleton — one instance links to exactly one SaaS team. * *

Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code * deviceSecret} so it can present it on every unattended entitlement call. It lives in the local diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index 018684b73f..c0cc901e1f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; /** - * Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance. + * Decides whether a request may proceed under combined billing on a self-hosted instance. * *

Rules (in order): * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 0cd71c9bda..9e73267561 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; /** - * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API / - * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through. + * Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI / + * automation) work when the instance is unlinked or over its limit; manual tools pass through. * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter. * *

Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java index 1ed49d6a8b..278380a5da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -16,11 +16,11 @@ import lombok.NoArgsConstructor; /** * The last time the instance metered a given input set this period — the local equivalent of the - * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow - * window: an identical input set re-submitted within the window (see {@link - * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the - * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job - * window so the same operation costs the same on the instance and in the cloud. + * cloud's lineage join (combined billing). The meter dedups on a rolling workflow window: an + * identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering}) + * is treated as workflow chaining and not re-charged, while the same inputs run again after the + * window are billed afresh — matching the cloud's 5-minute open-job window so the same operation + * costs the same on the instance and in the cloud. * *

{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java index 863f503f61..a31310a7b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -5,7 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ +/** Persistence for the per-period metered input-set signatures (combined billing). */ public interface MeteredInputSignatureRepository extends JpaRepository { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java index b90af07958..4c06089040 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -17,10 +17,10 @@ import lombok.NoArgsConstructor; import stirling.software.proprietary.billing.BillingCategory; /** - * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A". - * Each successful billable op increments its row; the daily sync reports the cumulative totals and - * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills - * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, + * Durable per-(billing period, category) cumulative usage counter for combined billing. Each + * successful billable op increments its row; the daily sync reports the cumulative totals and SaaS + * bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing) + * and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it. */ @Entity diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java index 2140775abc..3013d00a52 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; -/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ +/** Persistence for the per-period/per-category usage counters (combined billing). */ public interface UsageCounterRepository extends JpaRepository { /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java index 4c4ce2377c..a12a26eb8a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.billing.BillingCategory; /** - * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category - * usage to SaaS, which bills the delta against its own last-seen totals. + * Daily usage sender for combined billing. Reports each period's cumulative per-category usage to + * SaaS, which bills the delta against its own last-seen totals. * *

Resilience: the sync seq is persisted before the report so it never regresses across * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java index 232dd499dc..182c162f6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -11,9 +11,9 @@ import java.util.HexFormat; /** * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's - * meter (combined-billing "Mode A"), so both derive an identical signature for the same - * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent - * of file size), hardware-accelerated by the JVM where available. + * meter (combined billing), so both derive an identical signature for the same bytes — the + * basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file + * size), hardware-accelerated by the JVM where available. * *

Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}). diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 969111e9f5..2bce17d732 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor; import tools.jackson.databind.ObjectMapper; /** - * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register - * relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on - * unreachable) behaviour hold. + * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect + * handshake refuses an authorize URL it would not navigate to and carries no user token, and that + * entitlement parsing + the fail-open (null on unreachable) behaviour hold. */ class AccountLinkClientTest { @@ -48,39 +48,79 @@ class AccountLinkClientTest { return resp; } + // register() is gone with the JWT relay, and with it the two tests that asserted this client + // sends an Authorization: Bearer header. Nothing here carries a user token any more. + @Test @SuppressWarnings("unchecked") - void registerRelaysJwtAndParsesCredential() throws Exception { - // Build the stub response first: nesting response() inside when() trips Mockito's - // unfinished-stubbing check (inner when() runs mid outer when()). + void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception { + // The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail + // loudly here rather than reach the admin. HttpResponse resp = - response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}"); - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) - .thenReturn(resp); + response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); - AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server"); - - assertEquals("dev-1", result.deviceId()); - assertEquals("sec-1", result.deviceSecret()); - assertEquals(42L, result.teamId()); - - HttpRequest sent = captor.getValue(); - assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null)); - assertEquals( - "https://saas.example.com/api/v1/account-link/register", sent.uri().toString()); + assertThrows( + java.io.IOException.class, + () -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret")); } @Test @SuppressWarnings("unchecked") - void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception { - HttpResponse resp = response(401, "{\"error\":\"unauthorized\"}"); + void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception { + HttpResponse resp = + response( + 201, + "{\"requestId\":\"req-1\",\"expiresIn\":900," + + "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + AccountLinkClient.ConnectRequestResult result = + client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"); + + assertEquals("req-1", result.requestId()); + assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl()); + // No user token on this call, by design. + assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void connectClaimGrantsTheCredentialOnSuccess() throws Exception { + HttpResponse resp = + response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}"); when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); - AccountLinkClient.UpstreamException ex = - assertThrows( - AccountLinkClient.UpstreamException.class, - () -> client.register("jwt", null)); - assertEquals(401, ex.status()); + + AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret"); + + assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome()); + assertEquals("dev-1", result.deviceId()); + assertEquals("sec-1", result.deviceSecret()); + } + + @Test + @SuppressWarnings("unchecked") + void connectClaimMapsTheStatusItIsGiven() throws Exception { + // The whole point of these four: a claim consumes the request server-side, so + // reading 200 as anything but success loses the credential irrecoverably. + assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}")); + assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}")); + assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}")); + assertEquals( + AccountLinkClient.ConnectClaimOutcome.CONFIRMED, + claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}")); + } + + @SuppressWarnings("unchecked") + private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body) + throws Exception { + // Built before the when(), not inside it: response() stubs a mock of its own, and + // Mockito cannot have that happen mid-stubbing. + HttpResponse resp = response(status, body); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + return client.connectClaim("req-1", "secret").outcome(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java index 41f544de7e..6e13021a40 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest; - /** - * The local (self-hosted) account-link controller's error mapping: an upstream auth rejection - * surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport - * faults are a 502. + * The local (self-hosted) account-link controller's error mapping. Every upstream or transport + * failure is a 502, and the response body never echoes the exception, because a DNS or TLS message + * can carry the configured SaaS host. */ class AccountLinkControllerTest { private AccountLinkService service; + private ConnectService connectService; private UsageSyncService syncService; private ObjectProvider syncProvider; private AccountLinkController controller; @@ -32,47 +32,54 @@ class AccountLinkControllerTest { @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); + connectService = mock(ConnectService.class); syncService = mock(UsageSyncService.class); syncProvider = mock(ObjectProvider.class); controller = - new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); + new AccountLinkController( + service, connectService, mock(LocalUsageService.class), syncProvider); } - @Test - void link_missingJwt_returns400() { - ResponseEntity resp = controller.link(new LinkRequest(" ", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } + // These asserted POST /link's error mapping, which distinguished 401/403 so the portal could + // prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with + // it: connect/start carries no user token, so an upstream refusal is never the admin's session + // and everything non-transport is a plain gateway failure. @Test - void link_upstreamUnauthorized_maps401() throws Exception { - when(service.link("jwt", null)) - .thenThrow(new AccountLinkClient.UpstreamException(401, "bad token")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - void link_upstreamForbidden_maps403() throws Exception { - when(service.link("jwt", null)) - .thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - void link_upstreamServerError_maps502() throws Exception { - when(service.link("jwt", null)) + void connectStart_upstreamFailure_maps502() throws Exception { + when(connectService.start(any(), any())) .thenThrow(new AccountLinkClient.UpstreamException(500, "boom")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + + ResponseEntity resp = controller.connectStart(null, request()); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } @Test - void link_transportFailure_maps502() throws Exception { - when(service.link("jwt", null)).thenThrow(new IOException("connection refused")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception { + when(connectService.start(any(), any())) + .thenThrow(new IOException("connection refused to saas.internal:8081")); + + ResponseEntity resp = controller.connectStart(null, request()); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + // The body must not echo the exception: a DNS/TLS message can carry the configured SaaS + // host. + assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal"); + } + + @Test + void connectReauth_onAnUnlinkedServer_maps502() throws Exception { + when(connectService.startReauth(any())).thenThrow(new IOException("not linked")); + + ResponseEntity resp = controller.connectReauth(null, request()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } + + /** Minimal request: the controller only reads Origin and the forwarded/host details from it. */ + private static jakarta.servlet.http.HttpServletRequest request() { + return new org.springframework.mock.web.MockHttpServletRequest(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java index b909fb37a0..410ef8a8c7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java @@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.io.IOException; import java.time.LocalDateTime; import java.util.Optional; @@ -30,33 +28,25 @@ class AccountLinkServiceTest { service = new AccountLinkService(client, store, cache); } + // The two link() tests here are gone with the JWT relay. Storing a credential and invalidating + // the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what + // remains in this service is status and unlink. + @Test - void link_storesCredentialAndInvalidatesCache() throws IOException { - when(client.register("jwt", "name")) - .thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L)); + void status_linkedFromTheStoredCredential() { DeviceCredential stored = new DeviceCredential(); stored.setDeviceId("dev-1"); stored.setTeamId(7L); stored.setLinkedAt(LocalDateTime.now()); when(store.get()).thenReturn(Optional.of(stored)); - AccountLinkService.LinkStatus status = service.link("jwt", "name"); + AccountLinkService.LinkStatus status = service.status(); - verify(store).save("dev-1", "sec-1", 7L); - verify(cache).invalidate(); assertTrue(status.linked()); assertEquals("dev-1", status.deviceId()); assertEquals(7L, status.teamId()); } - @Test - void link_propagatesRegisterFailure() throws IOException { - when(client.register(any(), any())).thenThrow(new IOException("boom")); - org.junit.jupiter.api.Assertions.assertThrows( - IOException.class, () -> service.link("jwt", null)); - verify(cache, org.mockito.Mockito.never()).invalidate(); - } - @Test void status_unlinkedWhenNoCredential() { when(store.get()).thenReturn(Optional.empty()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java new file mode 100644 index 0000000000..5e040e4ad7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java @@ -0,0 +1,408 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult; +import stirling.software.proprietary.accountlink.ConnectService.Phase; + +/** Unit tests for the instance half of the connect handshake. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectServiceTest { + + private static final String NONCE = "the-nonce"; + private static final String CLAIM_SECRET = "the-claim-secret"; + private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1"; + + @Mock private AccountLinkClient client; + @Mock private ConnectStateRepository stateRepo; + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + + private ApplicationProperties applicationProperties; + private ConnectService service; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + service = + new ConnectService( + client, + stateRepo, + credentialStore, + entitlementCache, + applicationProperties); + } + + private void configureFrontendUrl(String url) { + applicationProperties.getSystem().setFrontendUrl(url); + } + + @Test + void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception { + configureFrontendUrl("https://pdf.example.com/"); + stubCreate(); + + service.start("prod-1", fromRequest("http://10.0.0.5:8080")); + + verify(client) + .connectRequest( + anyString(), + // Trailing slash trimmed, and the request's own view ignored. + org.mockito.ArgumentMatchers.eq( + "https://pdf.example.com" + ConnectService.CALLBACK_PATH), + anyString(), + anyString(), + // A first link carries no credential; that is what makes it a first link. + org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception { + stubCreate(); + + service.start(null, fromRequest("https://pdf.internal:8443/stirling")); + + ArgumentCaptor callback = ArgumentCaptor.forClass(String.class); + verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any()); + // Context path preserved, so a subpath deployment gets a callback that resolves. + assertThat(callback.getValue()) + .isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH); + } + + @Test + void start_withNoAddressAtAllFailsRatherThanGuessing() { + assertThat(catchIo(() -> service.start(null, fromRequest(null)))) + .hasMessageContaining("system.frontendUrl"); + verifyNoInteractions(client); + } + + @Test + void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() { + // The frontend is the only party that knows its router's base path. + String requested = "http://localhost:5173/app/account-link/callback"; + + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + requested, + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo(requested); + } + + @Test + void resolveCallback_ignoresACallbackFromADifferentOrigin() { + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + "https://evil.example.com/steal", + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() { + // The whole point: :5173 is where the admin is, :8080 is where the call landed. + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + null, "http://localhost:5173", "http://localhost:8080"))) + .isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_letsConfigurationBeatEverything() { + configureFrontendUrl("https://pdf.example.com/"); + + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + "http://localhost:5173/account-link/callback", + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_ignoresAnUnusableOriginHeader() { + // "null" is what a browser sends for an opaque origin; it must not become a callback. + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + null, "null", "http://localhost:8080"))) + .isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH); + } + + @Test + void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception { + stubCreate(); + + ConnectService.ConnectStatus status = + service.start(null, fromRequest("https://pdf.example.com")); + + assertThat(status.phase()).isEqualTo(Phase.PENDING); + // Not composed here: only the SaaS side knows where its approval page lives, so an + // instance configuring that could only get it wrong. + assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL); + } + + @Test + void start_keepsTheNonceAndClaimSecretItSent() throws Exception { + stubCreate(); + + service.start(null, fromRequest("https://pdf.example.com")); + + ArgumentCaptor nonce = ArgumentCaptor.forClass(String.class); + ArgumentCaptor secret = ArgumentCaptor.forClass(String.class); + verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectState.class); + verify(stateRepo).save(saved.capture()); + assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue()); + assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue()); + // Two independent secrets, not one value used twice. + assertThat(nonce.getValue()).isNotEqualTo(secret.getValue()); + } + + @Test + void start_whenAlreadyLinkedDoesNothing() throws Exception { + when(credentialStore.isLinked()).thenReturn(true); + when(credentialStore.get()).thenReturn(Optional.of(credential(7L))); + + ConnectService.ConnectStatus status = + service.start(null, fromRequest("https://pdf.example.com")); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + verifyNoInteractions(client); + verify(stateRepo, never()).save(any()); + } + + @Test + void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim("req-1", CLAIM_SECRET)) + .thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L)); + + ConnectService.ConnectStatus status = service.complete(NONCE); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + assertThat(status.teamId()).isEqualTo(7L); + verify(credentialStore).save("dev", "sec", 7L); + verify(entitlementCache).invalidate(); + verify(stateRepo).delete(state); + } + + @Test + void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + ConnectService.ConnectStatus status = service.complete("not-the-nonce"); + + assertThat(status.phase()).isEqualTo(Phase.REJECTED); + // The important half: an unverified caller cannot cancel a legitimate handshake. + verify(stateRepo, never()).delete(any()); + verifyNoInteractions(credentialStore); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void complete_withNoNonceAtAllIsRejected() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + + assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING); + verify(stateRepo, never()).delete(any()); + } + + @Test + void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE); + verify(stateRepo, never()).delete(any()); + verifyNoInteractions(credentialStore); + } + + @Test + void complete_whenDeclinedClearsTheHandshake() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED); + verify(stateRepo).delete(state); + verifyNoInteractions(credentialStore); + } + + @Test + void complete_onAnExpiredHandshakeClearsItWithoutClaiming() { + ConnectState state = openHandshake(); + state.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED); + verify(stateRepo).delete(state); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception { + when(credentialStore.get()).thenReturn(Optional.of(credential(7L))); + when(client.connectRequest(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL)); + + service.startReauth(fromRequest("https://pdf.example.com")); + + // Sending the credential is what makes the pinning trustworthy: the team comes from + // something only this instance holds. + verify(client) + .connectRequest( + any(), + anyString(), + anyString(), + anyString(), + org.mockito.ArgumentMatchers.argThat( + c -> c != null && "dev".equals(c.getDeviceId()))); + } + + @Test + void startReauth_onAnUnlinkedServerFails() { + assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com")))) + .hasMessageContaining("not linked"); + verifyNoInteractions(client); + } + + @Test + void complete_onAConfirmedReauthKeepsTheExistingCredential() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L)); + + ConnectService.ConnectStatus status = service.complete(NONCE); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + assertThat(status.teamId()).isEqualTo(7L); + // Nothing to store: a second credential would orphan the one we already hold. + verify(credentialStore, never()).save(anyString(), anyString(), any()); + verify(stateRepo).delete(state); + } + + @Test + void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() { + assertThat(service.status().phase()).isEqualTo(Phase.NONE); + } + + @Test + void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() { + ConnectState state = openHandshake(); + state.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + ConnectService.ConnectStatus status = service.status(); + + assertThat(status.phase()).isEqualTo(Phase.EXPIRED); + assertThat(status.authorizeUrl()).isNull(); + } + + @Test + void status_countsDownWhileAHandshakeIsOpen() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + + ConnectService.ConnectStatus status = service.status(); + + assertThat(status.phase()).isEqualTo(Phase.PENDING); + assertThat(status.secondsRemaining()).isPositive(); + assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1"); + } + + // --------------------------------------------------------------------------------------- + + /** A start with nothing but the reconstructed request URL, as a headless caller would send. */ + private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) { + return new ConnectService.CallbackHint(null, null, derivedBaseUrl); + } + + private void stubCreate() throws Exception { + // The five-argument overload: a first link passes a null credential rather than none. + when(client.connectRequest(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL)); + } + + private static ConnectState openHandshake() { + ConnectState state = new ConnectState(); + state.setId(ConnectState.SINGLETON_ID); + state.setRequestId("req-1"); + state.setNonce(NONCE); + state.setClaimSecret(CLAIM_SECRET); + state.setCallbackUrl("https://pdf.example.com/account-link/callback"); + state.setAuthorizeUrl("https://app.example.com/link?request=req-1"); + state.setCreatedAt(LocalDateTime.now()); + state.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + return state; + } + + private static DeviceCredential credential(Long teamId) { + DeviceCredential credential = new DeviceCredential(); + credential.setDeviceId("dev"); + credential.setDeviceSecret("sec"); + credential.setTeamId(teamId); + credential.setLinkedAt(LocalDateTime.now()); + return credential; + } + + /** Runs a throwing call and returns the exception, so the assertion reads in one line. */ + private static Throwable catchIo(ThrowingCall call) { + try { + call.run(); + throw new AssertionError("expected the call to fail"); + } catch (Exception e) { + return e; + } + } + + private interface ThrowingCall { + void run() throws Exception; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java index 646791f76e..4703c32496 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -4,14 +4,12 @@ import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.TeamMembership; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.repository.TeamMembershipRepository; -import stirling.software.saas.util.AuthenticationUtils; +import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam; -/** - * Account-link registration surface (combined-billing "Mode A"). - * - *

A self-hosted instance's local backend calls {@code POST /register} with the admin's - * short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new - * auth here). We resolve the caller's team, mint a device credential bound to it, and return the - * secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this - * JWT. - * - *

Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → - * 404. Leader-only, and the team is always derived from the caller (never the request body). - */ +/** Team-wide management of linked instances (combined billing). */ @Slf4j @Hidden @RestController @@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils; public class AccountLinkController { private final AccountLinkService service; - private final TeamMembershipRepository memberRepo; - private final UserRepository userRepository; + private final LeaderTeamResolver leaderTeams; - public AccountLinkController( - AccountLinkService service, - TeamMembershipRepository memberRepo, - UserRepository userRepository) { + public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) { this.service = service; - this.memberRepo = memberRepo; - this.userRepository = userRepository; + this.leaderTeams = leaderTeams; } - /** Optional display name for the instance (hostname / label). */ - public record RegisterRequest(String name) {} - - /** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */ - public record RegisterResponse( - Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {} - public record InstanceRow( Long instanceId, String deviceId, @@ -74,31 +44,10 @@ public class AccountLinkController { String lastSeenAt, boolean revoked) {} - @PostMapping("/register") - @PreAuthorize("isAuthenticated()") - public ResponseEntity register( - @RequestBody(required = false) RegisterRequest req, Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); - if (lt.error() != null) { - return ResponseEntity.status(lt.error()).build(); - } - String name = req != null ? req.name() : null; - AccountLinkService.RegisteredInstance reg = - service.register(lt.teamId(), lt.userId(), name); - return ResponseEntity.status(HttpStatus.CREATED) - .body( - new RegisterResponse( - reg.instanceId(), - lt.teamId(), - reg.deviceId(), - reg.deviceSecret(), - reg.name())); - } - @GetMapping("/instances") @PreAuthorize("isAuthenticated()") public ResponseEntity> list(Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); + LeaderTeam lt = leaderTeams.resolve(auth); if (lt.error() != null) { return ResponseEntity.status(lt.error()).build(); } @@ -124,38 +73,11 @@ public class AccountLinkController { @PostMapping("/instances/{instanceId}/revoke") @PreAuthorize("isAuthenticated()") public ResponseEntity revoke(@PathVariable Long instanceId, Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); + LeaderTeam lt = leaderTeams.resolve(auth); if (lt.error() != null) { return ResponseEntity.status(lt.error()).build(); } boolean ok = service.revoke(lt.teamId(), instanceId); return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); } - - // --------------------------------------------------------------------------------------- - // Helpers — team always derived from the caller; instance linking is a leader (billing) action. - // --------------------------------------------------------------------------------------- - - /** - * Resolved caller team, or an {@code error} status to return (teamId/userId null when error). - */ - private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {} - - private LeaderTeam resolveLeaderTeam(Authentication auth) { - User user; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (SecurityException e) { - return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); - } - List rows = memberRepo.findPrimaryMembership(user.getId()); - if (rows.isEmpty()) { - return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); - } - TeamMembership m = rows.getFirst(); - if (m.getRole() != TeamRole.LEADER) { - return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); - } - return new LeaderTeam(m.getTeam().getId(), user.getId(), null); - } } diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java index c31fc1b03e..fe6a0cc0e6 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java @@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; -/** - * Account-link instance registration + lifecycle (combined-billing "Mode A"). - * - *

Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound - * to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its - * unattended entitlement reads with that credential. - * - *

Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so - * {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404. - */ +/** Account-link instance registration + lifecycle (combined billing). */ @Slf4j @Service @Profile("saas") @@ -80,10 +71,7 @@ public class AccountLinkService { return repo.findByTeamIdOrderByCreatedAtDesc(teamId); } - /** - * Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by - * a different team (so a caller can never revoke another team's instance). Idempotent. - */ + /** Revokes an instance iff it belongs to {@code teamId}. */ @Transactional public boolean revoke(Long teamId, Long instanceId) { Optional found = repo.findById(instanceId); @@ -99,13 +87,30 @@ public class AccountLinkService { return true; } + /** + * Resolves an active instance from a device credential, or empty if it does not authenticate. + */ + @Transactional(readOnly = true) + public Optional resolveActiveInstance(String deviceId, String deviceSecret) { + if (deviceId == null || deviceSecret == null) { + return Optional.empty(); + } + return repo.findByDeviceIdAndRevokedAtIsNull(deviceId) + .filter( + instance -> + MessageDigest.isEqual( + sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8), + instance.getDeviceSecretHash() + .getBytes(StandardCharsets.UTF_8))); + } + private String randomSecret() { byte[] buf = new byte[SECRET_BYTES]; random.nextBytes(buf); return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); } - /** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */ + /** SHA-256 hex of a value. */ static String sha256Hex(String value) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java new file mode 100644 index 0000000000..95f2ba5de7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java @@ -0,0 +1,277 @@ +package stirling.software.saas.accountlink; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam; + +/** Browser-mediated "connect this server" handshake. */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/account-link/connect") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectController { + + /** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */ + static final String HEADER_DEVICE_ID = "X-Device-Id"; + + static final String HEADER_DEVICE_SECRET = "X-Device-Secret"; + + /** Frontend route serving the approval page. */ + static final String LINK_PATH = "/link"; + + private final ConnectRequestService service; + private final LeaderTeamResolver leaderTeams; + private final AccountLinkService accountLinkService; + private final ApplicationProperties applicationProperties; + + public ConnectController( + ConnectRequestService service, + LeaderTeamResolver leaderTeams, + AccountLinkService accountLinkService, + ApplicationProperties applicationProperties) { + this.service = service; + this.leaderTeams = leaderTeams; + this.accountLinkService = accountLinkService; + this.applicationProperties = applicationProperties; + } + + /** Sent by the instance's own backend, before it holds any credential. */ + public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {} + + /** {@code authorizeUrl} is where the instance should send its admin. */ + public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {} + + /** What the approval page renders. */ + public record ViewResponse( + String requestId, + String name, + String callbackOrigin, + boolean insecureTransport, + String mode, + String status) {} + + /** Where the approver's browser goes next, and the correlator the instance is waiting on. */ + public record ApproveResponse(String callbackUrl, String nonce) {} + + public record ClaimBody(String requestId, String claimSecret) {} + + public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {} + + /** Opens a handshake. */ + @PostMapping("/request") + public ResponseEntity request( + @RequestBody(required = false) CreateBody body, HttpServletRequest http) { + if (body == null) { + return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST")); + } + String deviceId = http.getHeader(HEADER_DEVICE_ID); + String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET); + boolean reauthRequested = deviceId != null || deviceSecret != null; + + ConnectRequestService.CreateResult result; + if (reauthRequested) { + Long pinnedTeamId = + accountLinkService + .resolveActiveInstance(deviceId, deviceSecret) + .map(LinkedInstance::getTeamId) + .orElse(null); + result = + service.createReauth( + body.name(), + body.callbackUrl(), + body.nonce(), + body.claimSecret(), + clientIp(http), + pinnedTeamId); + } else { + result = + service.create( + body.name(), + body.callbackUrl(), + body.nonce(), + body.claimSecret(), + clientIp(http)); + } + if (result.isRejected()) { + return switch (result.rejection()) { + case RATE_LIMITED -> + ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", "RATE_LIMITED")); + case BAD_CALLBACK -> + ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK")); + case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE")); + case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET")); + // A credential was offered and did not authenticate. Same answer as any other bad + // credential, and deliberately not distinguishable from "revoked". + case NOT_LINKED -> + ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "NOT_LINKED")); + }; + } + return ResponseEntity.status(HttpStatus.CREATED) + .body( + new CreateResponse( + result.requestId(), + result.expiresInSeconds(), + authorizeUrl(result.requestId(), http))); + } + + /** + * Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's + * own base URL, including any base path; without it the API's origin has to serve the app too. + */ + private String authorizeUrl(String requestId, HttpServletRequest http) { + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + String base = + frontendUrl != null && !frontendUrl.isBlank() + ? frontendUrl.strip().replaceAll("/+$", "") + : requestOrigin(http); + return base + + LINK_PATH + + "?request=" + + URLEncoder.encode(requestId, StandardCharsets.UTF_8); + } + + /** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */ + private static String requestOrigin(HttpServletRequest request) { + String proto = firstHop(request.getHeader("X-Forwarded-Proto")); + String host = firstHop(request.getHeader("X-Forwarded-Host")); + String scheme = proto != null ? proto : request.getScheme(); + // A forwarded host already carries its own port, if it needs one. + String hostPort = + host != null + ? host + : Origins.hostPort( + scheme, request.getServerName(), request.getServerPort()); + String context = request.getContextPath() == null ? "" : request.getContextPath(); + return scheme + "://" + hostPort + context; + } + + private static String firstHop(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String first = headerValue.split(",")[0].strip(); + return first.isEmpty() ? null : first; + } + + /** Detail for the approval page. */ + @GetMapping("/{requestId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity view(@PathVariable String requestId) { + return service.lookup(requestId) + .map( + v -> + ResponseEntity.ok( + new ViewResponse( + v.requestId(), + v.name(), + v.callbackOrigin(), + v.insecureTransport(), + v.mode().name(), + v.status().name()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** Approves a handshake. */ + @PostMapping("/{requestId}/approve") + @PreAuthorize("isAuthenticated()") + public ResponseEntity approve(@PathVariable String requestId, Authentication auth) { + Optional view = service.lookup(requestId); + if (view.isEmpty()) { + return ResponseEntity.notFound().build(); + } + boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH; + LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth); + if (lt.isError()) { + return ResponseEntity.status(lt.error()).build(); + } + ConnectRequestService.ApproveResult result = + service.approve(requestId, lt.teamId(), lt.userId()); + if (result.isRejected()) { + return switch (result.rejection()) { + // Named separately so the page can say "you are signed in to a different account" + // rather than implying the request itself was bad. + case WRONG_TEAM -> + ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "WRONG_TEAM")); + case UNAVAILABLE -> ResponseEntity.notFound().build(); + }; + } + return ResponseEntity.ok( + new ApproveResponse(result.target().callbackUrl(), result.target().nonce())); + } + + @PostMapping("/{requestId}/deny") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deny(@PathVariable String requestId, Authentication auth) { + LeaderTeam lt = leaderTeams.resolve(auth); + if (lt.isError()) { + return ResponseEntity.status(lt.error()).build(); + } + return service.deny(requestId) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + /** Collects the device credential. */ + @PostMapping("/claim") + public ResponseEntity claim(@RequestBody(required = false) ClaimBody body) { + if (body == null) { + return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST")); + } + ConnectRequestService.ClaimResult result = + service.claim(body.requestId(), body.claimSecret()); + return switch (result.outcome()) { + case GRANTED -> + ResponseEntity.ok( + new ClaimResponse( + result.deviceId(), result.deviceSecret(), result.teamId())); + // A re-authentication carries no credential: the instance already has one. It only + // needs to know the browser leg succeeded, and which team it was confirmed against. + case CONFIRMED -> + ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId())); + case PENDING -> + ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending")); + case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED")); + }; + } + + /** + * Source address for the creation cap. + * + *

Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on + * it lets one rotate fake addresses and have no cap at all. {@code + * server.forward-headers-strategy} is NATIVE, so the container has already resolved the real + * client from trusted proxies. + */ + private static String clientIp(HttpServletRequest request) { + String remote = request.getRemoteAddr(); + return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java new file mode 100644 index 0000000000..f9c204eef3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java @@ -0,0 +1,103 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** One in-flight "connect this server" handshake. Short lived and single use. */ +@Entity +@Table( + name = "account_link_connect_request", + indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at")) +@Getter +@Setter +@NoArgsConstructor +public class ConnectRequest { + + public enum Mode { + LINK, + REAUTH + } + + public enum Status { + PENDING, + APPROVED, + DENIED, + CONSUMED + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "request_id", nullable = false, unique = true, length = 64) + private String requestId; + + @Column(name = "name", length = 255) + private String name; + + /** + * Read back from here on approval, never from the request: that is what stops an open redirect. + */ + @Column(name = "callback_url", nullable = false, length = 2048) + private String callbackUrl; + + @Column(name = "callback_origin", nullable = false, length = 255) + private String callbackOrigin; + + @Column(name = "nonce", nullable = false, length = 128) + private String nonce; + + /** SHA-256; the secret itself is never stored. */ + @Column(name = "claim_secret_hash", nullable = false, length = 64) + private String claimSecretHash; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 16) + private Mode mode = Mode.LINK; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private Status status = Status.PENDING; + + /** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */ + @Column(name = "team_id") + private Long teamId; + + @Column(name = "approved_by_user_id") + private Long approvedByUserId; + + @Column(name = "requester_ip", length = 45) + private String requesterIp; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "consumed_at") + private LocalDateTime consumedAt; + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && expiresAt.isBefore(now); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java new file mode 100644 index 0000000000..7712ee297a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java @@ -0,0 +1,47 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Removes connect requests that are past use. + * + *

Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and + * nothing else deletes one. Requests hold a callback URL and the requester's address, so they are + * swept soon after expiry rather than kept. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +@RequiredArgsConstructor +public class ConnectRequestCleanupService { + + /** Long enough to answer "what happened to my link?" the next morning, and no longer. */ + private static final int RETAIN_HOURS = 24; + + private final ConnectRequestRepository repo; + + @Scheduled(cron = "0 30 3 * * *") + @Transactional + public void purgeExpired() { + try { + LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS); + int deleted = repo.deleteByExpiresAtBefore(cutoff); + if (deleted > 0) { + log.info("Account-link connect: purged {} expired requests", deleted); + } + } catch (Exception e) { + // A failed sweep must not take the scheduler down; the next run retries. + log.error("Account-link connect: purge failed", e); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java new file mode 100644 index 0000000000..6c5dec098b --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java @@ -0,0 +1,28 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +/** Data access for {@link ConnectRequest}. */ +public interface ConnectRequestRepository extends JpaRepository { + + Optional findByRequestId(String requestId); + + /** Row-locking read used by approve, deny and claim. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId") + Optional findByRequestIdForUpdate(@Param("requestId") String requestId); + + /** Backs the per-IP creation cap, since creating a request needs no authentication. */ + long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after); + + /** Sweeps rows past use, whatever they settled as. Anyone can create these. */ + int deleteByExpiresAtBefore(LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java new file mode 100644 index 0000000000..64abc12fa6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java @@ -0,0 +1,391 @@ +package stirling.software.saas.accountlink; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +/** The "connect this server" handshake, SaaS side. */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectRequestService { + + /** + * Long enough for the approver to sign in, pick the right account and read the origin. Sized + * for the slowest real route: signing up, waiting for a confirmation email, and coming back. + */ + static final int LIFETIME_MINUTES = 30; + + /** Creating a request needs no authentication, so the only brake is per-source volume. */ + static final int MAX_REQUESTS_PER_IP = 10; + + private static final int REQUEST_ID_BYTES = 32; + private static final int MAX_NONCE_LENGTH = 128; + private static final int MAX_CALLBACK_LENGTH = 2048; + private static final int MAX_NAME_LENGTH = 255; + + private final ConnectRequestRepository repo; + private final AccountLinkService accountLinkService; + private final SecureRandom random = new SecureRandom(); + + public ConnectRequestService( + ConnectRequestRepository repo, AccountLinkService accountLinkService) { + this.repo = repo; + this.accountLinkService = accountLinkService; + } + + /** Rejected creation attempts, so the controller can pick a status without parsing messages. */ + public enum CreateRejection { + BAD_CALLBACK, + BAD_NONCE, + BAD_SECRET, + RATE_LIMITED, + /** + * A re-authentication was asked for by something that could not prove it is a linked + * instance. + */ + NOT_LINKED + } + + /** Either a created request id, or the reason we would not create one. */ + public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) { + static CreateResult ok(String requestId, int expiresInSeconds) { + return new CreateResult(requestId, expiresInSeconds, null); + } + + static CreateResult rejected(CreateRejection rejection) { + return new CreateResult(null, 0, rejection); + } + + public boolean isRejected() { + return rejection != null; + } + } + + /** What the approval page shows. */ + public record ConnectView( + String requestId, + String name, + String callbackOrigin, + boolean insecureTransport, + ConnectRequest.Mode mode, + ConnectRequest.Status status) {} + + /** Where to send the browser once approved, plus the correlator the instance is expecting. */ + public record ApprovalTarget(String callbackUrl, String nonce) {} + + public enum ClaimOutcome { + /** Approved and collected; {@code credential} is populated. */ + GRANTED, + /** A re-authentication was approved. */ + CONFIRMED, + /** Still waiting on a human. */ + PENDING, + /** Declined, expired, unknown, already collected, or a bad claim secret. */ + REJECTED + } + + public record ClaimResult( + ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) { + static ClaimResult of(ClaimOutcome outcome) { + return new ClaimResult(outcome, null, null, null); + } + } + + /** Records a handshake on behalf of an instance that has no credential yet. */ + @Transactional + public CreateResult create( + String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) { + return create(name, callbackUrl, nonce, claimSecret, requesterIp, null); + } + + /** + * As {@link #create}, but for an instance that is already linked and only needs its admin's + * browser signed in again. + */ + @Transactional + public CreateResult createReauth( + String name, + String callbackUrl, + String nonce, + String claimSecret, + String requesterIp, + Long pinnedTeamId) { + if (pinnedTeamId == null) { + return CreateResult.rejected(CreateRejection.NOT_LINKED); + } + return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId); + } + + private CreateResult create( + String name, + String callbackUrl, + String nonce, + String claimSecret, + String requesterIp, + Long pinnedTeamId) { + if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) { + return CreateResult.rejected(CreateRejection.BAD_NONCE); + } + if (claimSecret == null || claimSecret.isBlank()) { + return CreateResult.rejected(CreateRejection.BAD_SECRET); + } + Optional parsed = validateCallback(callbackUrl); + if (parsed.isEmpty()) { + return CreateResult.rejected(CreateRejection.BAD_CALLBACK); + } + LocalDateTime now = LocalDateTime.now(); + if (requesterIp != null + && repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1)) + >= MAX_REQUESTS_PER_IP) { + return CreateResult.rejected(CreateRejection.RATE_LIMITED); + } + + URI uri = parsed.get(); + ConnectRequest request = new ConnectRequest(); + request.setRequestId(randomToken()); + request.setName(trim(name, MAX_NAME_LENGTH)); + request.setCallbackUrl(uri.toString()); + request.setCallbackOrigin(originOf(uri)); + request.setNonce(nonce); + request.setClaimSecretHash(sha256Hex(claimSecret)); + request.setStatus(ConnectRequest.Status.PENDING); + request.setMode( + pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH); + request.setTeamId(pinnedTeamId); + request.setRequesterIp(requesterIp); + request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES)); + repo.save(request); + + // Never log the nonce or the claim secret; both are live. The request id is the safe + // handle for correlating a support request against this row. + log.info( + "Account-link connect: request {} created for origin {}", + request.getRequestId(), + request.getCallbackOrigin()); + return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60); + } + + /** The approver's view of a handshake. */ + @Transactional(readOnly = true) + public Optional lookup(String requestId) { + return repo.findByRequestId(requestId) + .filter(r -> !r.isExpired(LocalDateTime.now())) + .map( + r -> + new ConnectView( + r.getRequestId(), + r.getName(), + r.getCallbackOrigin(), + !"https".equals(schemeOf(r.getCallbackOrigin())), + r.getMode(), + r.getStatus())); + } + + /** Why an approval was refused, so the page can say something useful. */ + public enum ApproveRejection { + /** Unknown, expired, or already settled. */ + UNAVAILABLE, + /** The approver's team is not the team this server already belongs to. */ + WRONG_TEAM + } + + public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) { + public boolean isRejected() { + return target == null; + } + } + + /** Binds a pending handshake to the approver's team and returns where to send them next. */ + @Transactional + public ApproveResult approve(String requestId, Long teamId, Long userId) { + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return new ApproveResult(null, ApproveRejection.UNAVAILABLE); + } + ConnectRequest request = found.get(); + LocalDateTime now = LocalDateTime.now(); + if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) { + return new ApproveResult(null, ApproveRejection.UNAVAILABLE); + } + Long pinned = request.getTeamId(); + if (pinned != null && !pinned.equals(teamId)) { + log.warn( + "Account-link connect: request {} approved by team {} but is pinned to team {};" + + " refusing", + requestId, + teamId, + pinned); + return new ApproveResult(null, ApproveRejection.WRONG_TEAM); + } + request.setStatus(ConnectRequest.Status.APPROVED); + request.setTeamId(teamId); + request.setApprovedByUserId(userId); + request.setApprovedAt(now); + repo.save(request); + log.info( + "Account-link connect: request {} approved for team {} ({})", + requestId, + teamId, + request.getMode()); + return new ApproveResult( + new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null); + } + + /** Declines a pending handshake. */ + @Transactional + public boolean deny(String requestId) { + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return false; + } + ConnectRequest request = found.get(); + if (request.getStatus() != ConnectRequest.Status.PENDING) { + return false; + } + request.setStatus(ConnectRequest.Status.DENIED); + repo.save(request); + log.info("Account-link connect: request {} denied", requestId); + return true; + } + + /** Collects the device credential for an approved handshake. */ + @Transactional + public ClaimResult claim(String requestId, String claimSecret) { + if (requestId == null || claimSecret == null) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + ConnectRequest request = found.get(); + if (!secretMatches(claimSecret, request.getClaimSecretHash())) { + // Same answer as an unknown id: a caller probing ids learns nothing from the + // difference. + log.warn("Account-link connect: claim for request {} had a bad secret", requestId); + return ClaimResult.of(ClaimOutcome.REJECTED); + } + if (request.isExpired(LocalDateTime.now())) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + return switch (request.getStatus()) { + case PENDING -> ClaimResult.of(ClaimOutcome.PENDING); + case APPROVED -> mint(request); + case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED); + }; + } + + /** Settles an approved handshake. */ + private ClaimResult mint(ConnectRequest request) { + if (request.getMode() == ConnectRequest.Mode.REAUTH) { + request.setStatus(ConnectRequest.Status.CONSUMED); + request.setConsumedAt(LocalDateTime.now()); + repo.save(request); + log.info( + "Account-link connect: request {} re-authenticated for team {}", + request.getRequestId(), + request.getTeamId()); + return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId()); + } + AccountLinkService.RegisteredInstance registered = + accountLinkService.register( + request.getTeamId(), request.getApprovedByUserId(), request.getName()); + request.setStatus(ConnectRequest.Status.CONSUMED); + request.setConsumedAt(LocalDateTime.now()); + repo.save(request); + log.info( + "Account-link connect: request {} claimed, instance {} bound to team {}", + request.getRequestId(), + registered.instanceId(), + request.getTeamId()); + return new ClaimResult( + ClaimOutcome.GRANTED, + registered.deviceId(), + registered.deviceSecret(), + request.getTeamId()); + } + + /** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */ + static Optional validateCallback(String candidate) { + if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) { + return Optional.empty(); + } + URI uri; + try { + uri = new URI(candidate.strip()); + } catch (URISyntaxException e) { + return Optional.empty(); + } + if (!uri.isAbsolute() || uri.getScheme() == null) { + return Optional.empty(); + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return Optional.empty(); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + return Optional.empty(); + } + if (uri.getUserInfo() != null || uri.getFragment() != null) { + return Optional.empty(); + } + return Optional.of(uri); + } + + /** Scheme, host and port, with the default port omitted so origins compare cleanly. */ + static String originOf(URI uri) { + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort()); + } + + private static String schemeOf(String origin) { + int sep = origin.indexOf("://"); + return sep < 0 ? "" : origin.substring(0, sep); + } + + private static String trim(String value, int max) { + if (value == null) { + return null; + } + String stripped = value.strip(); + if (stripped.isEmpty()) { + return null; + } + return stripped.length() <= max ? stripped : stripped.substring(0, max); + } + + private String randomToken() { + byte[] buf = new byte[REQUEST_ID_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */ + private static boolean secretMatches(String candidate, String expectedHash) { + if (expectedHash == null) { + return false; + } + return MessageDigest.isEqual( + sha256Hex(candidate).getBytes(StandardCharsets.UTF_8), + expectedHash.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256Hex(String value) { + return AccountLinkService.sha256Hex(value); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java index a2fd13a095..17cb340d60 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java @@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; /** - * Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A"). + * Authenticates a linked self-hosted instance by its device credential (combined billing). * *

Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link * LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7c98d5e59b..9c313a1dfa 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; /** - * Instance-facing surface (combined-billing "Mode A"), authenticated by the device - * credential — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device - * credential is scoped here and nowhere else. + * Instance-facing surface (combined billing), authenticated by the device credential — not a + * user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped + * here and nowhere else. * *

{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid * device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java new file mode 100644 index 0000000000..7118562a28 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java @@ -0,0 +1,68 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** Who is allowed to bind a self-hosted instance to a team. */ +@Component +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class LeaderTeamResolver { + + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) { + this.memberRepo = memberRepo; + this.userRepository = userRepository; + } + + /** + * Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null). + */ + public record LeaderTeam(Long teamId, Long userId, HttpStatus error) { + public boolean isError() { + return error != null; + } + } + + /** Caller must lead their team. */ + public LeaderTeam resolve(Authentication auth) { + return resolve(auth, true); + } + + /** Caller need only belong to a team. */ + public LeaderTeam resolveMember(Authentication auth) { + return resolve(auth, false); + } + + private LeaderTeam resolve(Authentication auth, boolean requireLeader) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + TeamMembership membership = rows.getFirst(); + if (requireLeader && membership.getRole() != TeamRole.LEADER) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + return new LeaderTeam(membership.getTeam().getId(), user.getId(), null); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java index ec92c97758..f460e82d5b 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java @@ -16,7 +16,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code + * One self-hosted instance that has linked a SaaS account (combined billing, {@code * linked_instance}, V22). * *

Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java index af883bd66a..e393a162db 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java @@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; /** - * Authentication for a linked self-hosted instance (combined-billing "Mode A"). + * Authentication for a linked self-hosted instance (combined billing). * *

Deliberately not a user: the principal is the instance ({@code instanceId}) bound to * a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java b/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java new file mode 100644 index 0000000000..629e0f7fc1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java @@ -0,0 +1,22 @@ +package stirling.software.saas.accountlink; + +/** + * Origin formatting shared by the connect handshake. + * + *

One place on purpose: the origin a request arrives on and the origin parsed out of a callback + * URL are compared with each other, so if either side stopped omitting the default port the + * comparison would start failing quietly. + */ +final class Origins { + + private Origins() {} + + /** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */ + static String hostPort(String scheme, String host, int port) { + boolean isDefault = + port <= 0 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return isDefault ? host : host + ":" + port; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java index dffd1eccf1..7853c56856 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -41,6 +41,7 @@ public final class SaasSchemaOwnership { */ public static final Set MIGRATION_OWNED = Set.of( + "account_link_connect_request", "ai_create_sessions", "audit_events", "authorities", @@ -78,6 +79,7 @@ public final class SaasSchemaOwnership { */ public static final Set HIBERNATE_MANAGED = Set.of( + "account_link_connect_state", "account_link_device_credential", "account_link_metered_signature", "account_link_sync_state", diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java index c672540870..67e9c581d7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType; import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; /** - * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a - * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the - * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, - * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and - * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The - * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}. + * Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic + * cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via + * {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT, + * Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a + * backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at + * the instance gate, not here. Gated behind {@code account-link.enabled}. */ @Slf4j @Service diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java index 45a6435746..345cf46b75 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java @@ -19,9 +19,9 @@ import lombok.Setter; /** * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team, - * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative - * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via - * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays. + * billing period, category)} (combined billing). The instance reports monotonic cumulative unit + * totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the + * standard charge path and advances this row. {@code lastSyncSeq} dedups replays. */ @Entity @Table( diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 36f3a4c06e..ea1c9bca9b 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.Customizer; @@ -71,6 +72,7 @@ public class SupabaseSecurityConfig { private final SaasTeamService saasTeamService; private final ApplicationProperties applicationProperties; private final ApiKeyAuthenticationService apiKeyAuthenticationService; + private final Environment environment; @Value("${app.supabase.issuer:}") private String issuer; @@ -105,6 +107,17 @@ public class SupabaseSecurityConfig { .permitAll() .requestMatchers("/actuator/health", "/api/v1/config/**") .permitAll() + // Account-link connect handshake: an instance calls these + // before it holds any credential, so there is nothing to + // authenticate with yet. Neither grants anything on its + // own — /request records an intent a human must approve, + // and /claim requires a secret only the instance that + // created the request has ever held. + .requestMatchers( + HttpMethod.POST, + "/api/v1/account-link/connect/request", + "/api/v1/account-link/connect/claim") + .permitAll() .requestMatchers( req -> RequestUriUtils.isStaticResource( @@ -144,7 +157,7 @@ public class SupabaseSecurityConfig { SupabaseSecurityConfig ::toAuthentication))); - // Device-credential auth for linked self-hosted instances (combined-billing Mode A). + // Device-credential auth for linked self-hosted instances (combined billing). // The filter bean exists only when stirling.billing.account-link.enabled=true; when off it // is absent here, so the instance surface cannot authenticate at all until release. DeviceCredentialAuthenticationFilter deviceFilter = @@ -268,6 +281,28 @@ public class SupabaseSecurityConfig { } } + /** + * Loopback on any port, as Spring origin patterns. Only added outside production; see {@link + * #corsConfigurationSource()}. + */ + private static final List LOOPBACK_ANY_PORT = + List.of("http://localhost:[*]", "http://127.0.0.1:[*]"); + + /** + * Profiles that mean "a developer's machine or a preview environment", never the production + * deployment. Production runs the bare {@code saas} profile. + */ + private static final List NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local"); + + private boolean isNonProduction() { + for (String profile : environment.getActiveProfiles()) { + if (NON_PRODUCTION_PROFILES.contains(profile)) { + return true; + } + } + return false; + } + @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); @@ -297,7 +332,23 @@ public class SupabaseSecurityConfig { origins.add(desktopOrigin); } } - if (origins.stream().anyMatch(o -> o.contains("*"))) { + // Outside production, allow loopback on ANY port. Several dev servers run side by side + // (editor, saas web app, one per flavour under test) and their ports move, so pinning a + // list means every new local environment shows up as an opaque CORS failure. Unlike a + // wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this + // machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in + // production, where the profile check below is false. + if (!operatorOverride && isNonProduction()) { + origins.addAll(LOOPBACK_ANY_PORT); + log.info( + "Non-production profile active: allowing loopback CORS origins on any port {}", + LOOPBACK_ANY_PORT); + } + // Loopback port wildcards are exempt: the warning below is about hostname takeover, which + // does not apply to an origin only this machine can serve. + if (origins.stream() + .filter(o -> !LOOPBACK_ANY_PORT.contains(o)) + .anyMatch(o -> o.contains("*"))) { log.warn( "CORS origins contain a wildcard paired with allowCredentials=true: {}." + " Wildcard subdomains can be taken over by an attacker (lapsed DNS," diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 0038cca42a..d81e0d9ee7 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -519,8 +519,8 @@ public class SaasTeamService { * membership and its wallet) rather than deleting it, so a plain team is never orphaned. The * only real hazard is a team the user is the last leader of that still carries live * billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance - * ("Mode A"). Those block the join until the plan is cancelled / leadership transferred / - * instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. + * (combined billing). Those block the join until the plan is cancelled / leadership transferred + * / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. * *

The home team and the team being joined are excluded: neither is left by the join (home is * parked, the joined team is kept), so their live billing cannot be stranded. diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java index e12888edb3..b9c6f22305 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamMembershipRepository; -import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; -import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; +import stirling.software.saas.accountlink.AccountLinkController.InstanceRow; import stirling.software.saas.util.AuthenticationUtils; /** @@ -44,20 +44,27 @@ class AccountLinkControllerTest { @BeforeEach void setUp() { - controller = new AccountLinkController(service, memberRepo, userRepository); + // Real resolver over the mocked repositories: the leader ladder moved into + // LeaderTeamResolver, and these tests are still asserting that ladder's behaviour + // through the controller. + controller = + new AccountLinkController( + service, new LeaderTeamResolver(memberRepo, userRepository)); auth = new AnonymousAuthenticationToken( "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); } + // The leader ladder used to be asserted through POST /register, which has been removed along + // with the JWT relay. It is exercised through /instances instead: same resolver, same rungs. + @Test - void register_unauthenticated_returns401() { + void list_unauthenticated_returns401() { try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenThrow(new SecurityException("not authenticated")); - ResponseEntity resp = - controller.register(new RegisterRequest("host"), auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verifyNoInteractions(service); @@ -65,14 +72,14 @@ class AccountLinkControllerTest { } @Test - void register_noMembership_returns403() { + void list_noMembership_returns403() { User user = mockUser(42L); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); - ResponseEntity resp = controller.register(null, auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(service); @@ -80,7 +87,7 @@ class AccountLinkControllerTest { } @Test - void register_nonLeader_returns403() { + void list_nonLeader_returns403() { User user = mockUser(42L); TeamMembership member = membership(7L, TeamRole.MEMBER); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { @@ -88,7 +95,7 @@ class AccountLinkControllerTest { .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member)); - ResponseEntity resp = controller.register(null, auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(service); @@ -96,27 +103,20 @@ class AccountLinkControllerTest { } @Test - void register_leader_mintsCredentialForCallerTeam() { + void list_leader_readsOnlyTheCallersTeam() { User user = mockUser(42L); TeamMembership leader = membership(7L, TeamRole.LEADER); - when(service.register(7L, 42L, "host")) - .thenReturn( - new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host")); + when(service.list(7L)).thenReturn(List.of()); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); - ResponseEntity resp = - controller.register(new RegisterRequest("host"), auth); + ResponseEntity> resp = controller.list(auth); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); - RegisterResponse body = resp.getBody(); - assertThat(body).isNotNull(); - // Team comes from the caller's membership and is surfaced in the response. - assertThat(body.teamId()).isEqualTo(7L); - assertThat(body.instanceId()).isEqualTo(99L); - assertThat(body.deviceSecret()).isEqualTo("sec-x"); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + // The team comes from the caller's membership, never from the request. + verify(service).list(7L); } } diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java new file mode 100644 index 0000000000..d58c4c4098 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.saas.accountlink.ConnectController.CreateBody; +import stirling.software.saas.accountlink.ConnectController.CreateResponse; + +/** + * The authorize URL the instance is told to send its admin to. Everything else on this controller + * delegates; this is the only decision it makes on its own. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectControllerTest { + + private static final CreateBody BODY = + new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s"); + + @Mock private ConnectRequestService service; + @Mock private LeaderTeamResolver leaderTeams; + @Mock private AccountLinkService accountLinkService; + + private ApplicationProperties applicationProperties; + private ConnectController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = + new ConnectController( + service, leaderTeams, accountLinkService, applicationProperties); + when(service.create(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800)); + } + + private String authorizeUrl(MockHttpServletRequest request) { + Object body = controller.request(BODY, request).getBody(); + assertThat(body).isInstanceOf(CreateResponse.class); + return ((CreateResponse) body).authorizeUrl(); + } + + private static MockHttpServletRequest request(String scheme, String host, int port) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme(scheme); + request.setServerName(host); + request.setServerPort(port); + return request; + } + + @Test + void prefersTheConfiguredFrontendUrl() { + applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/"); + + // Trailing slash trimmed, base path kept, and the API's own origin ignored. + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://app.example.com/app/link?request=req-1"); + } + + @Test + void fallsBackToTheOriginTheApiWasReachedOn() { + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void keepsANonDefaultPortAndTheContextPath() { + MockHttpServletRequest request = request("http", "localhost", 8081); + request.setContextPath("/stirling"); + + assertThat(authorizeUrl(request)) + .isEqualTo("http://localhost:8081/stirling/link?request=req-1"); + } + + @Test + void honoursTheForwardedSchemeAndHost() { + MockHttpServletRequest request = request("http", "10.0.0.5", 8080); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "api.example.com"); + + assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void takesOnlyTheFirstForwardedHop() { + MockHttpServletRequest request = request("http", "10.0.0.5", 8080); + request.addHeader("X-Forwarded-Proto", "https, http"); + request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com"); + + assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void percentEncodesTheRequestId() { + when(service.create(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800)); + + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://api.example.com/link?request=a+b%26c"); + } + + @Test + void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() { + assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody()) + .isEqualTo(java.util.Map.of("error", "BAD_REQUEST")); + } + + @Test + void offeringNoCredentialTakesTheFirstLinkPath() { + authorizeUrl(request("https", "api.example.com", 443)); + + // createReauth is the credentialled path; a first link must not reach it. + org.mockito.Mockito.verify(service, org.mockito.Mockito.never()) + .createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java new file mode 100644 index 0000000000..f75c96becb --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java @@ -0,0 +1,358 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome; +import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection; + +/** + * Unit tests for the connect handshake's security properties, which are the reason this flow is + * safe rather than an open redirect: the callback is validated once and then read back from + * storage, the claim secret authenticates the collection, and one approval mints exactly one + * credential. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectRequestServiceTest { + + private static final String CALLBACK = "https://pdf.example.com/account-link/callback"; + private static final String NONCE = "nonce-value"; + private static final String CLAIM_SECRET = "claim-secret-value"; + + @Mock private ConnectRequestRepository repo; + @Mock private AccountLinkService accountLinkService; + + private ConnectRequestService service; + + @BeforeEach + void setUp() { + service = new ConnectRequestService(repo, accountLinkService); + } + + @Test + void create_storesTheValidatedCallbackAndItsOrigin() { + ConnectRequestService.CreateResult result = + service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1"); + + assertThat(result.isRejected()).isFalse(); + assertThat(result.requestId()).isNotBlank(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + ConnectRequest row = saved.getValue(); + assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK); + assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com"); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING); + assertThat(row.getName()).isEqualTo("prod-1"); + // The claim secret is only ever stored as a hash. + assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64); + } + + @Test + void create_keepsANonDefaultPortInTheOrigin() { + service.create( + null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "/account-link/callback", // not absolute + "ftp://pdf.example.com/cb", // wrong scheme + "javascript:alert(1)", // not a hierarchical http(s) URL + "https://user:pw@pdf.example.com/cb", // credentials in the URL + "https://pdf.example.com/cb#already", // would collide with our fragment + "https:///cb" // no host + }) + void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) { + ConnectRequestService.CreateResult result = + service.create(null, callback, NONCE, CLAIM_SECRET, null); + + assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK); + verify(repo, never()).save(any()); + } + + @Test + void create_refusesAMissingNonce() { + assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection()) + .isEqualTo(CreateRejection.BAD_NONCE); + verify(repo, never()).save(any()); + } + + @Test + void create_namesTheSecretWhenTheSecretIsWhatIsMissing() { + assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection()) + .isEqualTo(CreateRejection.BAD_SECRET); + verify(repo, never()).save(any()); + } + + @Test + void create_isCappedPerSourceAddress() { + when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any())) + .thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP); + + ConnectRequestService.CreateResult result = + service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1"); + + assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED); + verify(repo, never()).save(any()); + } + + @Test + void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() { + ConnectRequest row = pending(); + row.setCallbackOrigin("http://pdf.internal:8080"); + when(repo.findByRequestId("req")).thenReturn(Optional.of(row)); + + assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true); + } + + @Test + void lookup_hidesAnExpiredHandshake() { + ConnectRequest row = pending(); + row.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(repo.findByRequestId("req")).thenReturn(Optional.of(row)); + + assertThat(service.lookup("req")).isEmpty(); + } + + @Test + void approve_bindsTheTeamAndReturnsTheStoredCallback() { + ConnectRequest row = pending(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L); + + assertThat(result.isRejected()).isFalse(); + // The destination comes from the row, never from the caller. + assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK); + assertThat(result.target().nonce()).isEqualTo(NONCE); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + assertThat(row.getTeamId()).isEqualTo(7L); + assertThat(row.getApprovedByUserId()).isEqualTo(42L); + // Approval on its own must not mint anything. + verifyNoInteractions(accountLinkService); + } + + @Test + void approve_isSingleUse() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.APPROVED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue(); + } + + @Test + void approve_refusesAnExpiredHandshake() { + ConnectRequest row = pending(); + row.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue(); + } + + @Test + void createReauth_pinsTheTeamItWasToldByTheCredential() { + ConnectRequestService.CreateResult result = + service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L); + + assertThat(result.isRejected()).isFalse(); + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH); + assertThat(saved.getValue().getTeamId()).isEqualTo(7L); + } + + @Test + void createReauth_withoutAnAuthenticatedInstanceIsRefused() { + // The controller passes null when the offered device credential did not authenticate. + assertThat( + service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null) + .rejection()) + .isEqualTo(CreateRejection.NOT_LINKED); + verify(repo, never()).save(any()); + } + + @Test + void create_leavesTheTeamOpenForAFirstLink() { + service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK); + // Approval is what decides the team on a first link. + assertThat(saved.getValue().getTeamId()).isNull(); + } + + @Test + void approve_refusesAnApproverFromADifferentTeam() { + ConnectRequest row = reauthPinnedTo(7L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L); + + // This is the "signed in to the wrong account" case, and it must not silently rebind. + assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING); + assertThat(row.getTeamId()).isEqualTo(7L); + } + + @Test + void approve_acceptsTheTeamTheServerAlreadyBelongsTo() { + ConnectRequest row = reauthPinnedTo(7L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse(); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + } + + @Test + void claim_onAReauthConfirmsWithoutMintingASecondCredential() { + ConnectRequest row = reauthPinnedTo(7L); + row.setStatus(ConnectRequest.Status.APPROVED); + row.setApprovedByUserId(42L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET); + + assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED); + assertThat(result.deviceId()).isNull(); + assertThat(result.deviceSecret()).isNull(); + assertThat(result.teamId()).isEqualTo(7L); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED); + // A second credential would orphan the one the instance already holds. + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_mintsOnceForAnApprovedHandshake() { + ConnectRequest row = approved(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + when(accountLinkService.register(anyLong(), anyLong(), any())) + .thenReturn( + new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n")); + + ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET); + + assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED); + assertThat(result.deviceId()).isEqualTo("dev-id"); + assertThat(result.deviceSecret()).isEqualTo("dev-secret"); + assertThat(result.teamId()).isEqualTo(7L); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED); + verify(accountLinkService).register(7L, 42L, "n"); + } + + @Test + void claim_refusesASecondCollection() { + ConnectRequest row = approved(); + row.setStatus(ConnectRequest.Status.CONSUMED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_withTheWrongSecretMintsNothing() { + ConnectRequest row = approved(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", "not-the-secret").outcome()) + .isEqualTo(ClaimOutcome.REJECTED); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_beforeApprovalTellsTheInstanceToWait() { + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending())); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_afterDenialIsTerminal() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.DENIED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_onAnExpiredHandshakeMintsNothing() { + ConnectRequest row = approved(); + row.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_forAnUnknownIdLooksTheSameAsABadSecret() { + when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty()); + + assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + } + + // --------------------------------------------------------------------------------------- + + private static ConnectRequest pending() { + ConnectRequest row = new ConnectRequest(); + row.setRequestId("req"); + row.setName("n"); + row.setCallbackUrl(CALLBACK); + row.setCallbackOrigin("https://pdf.example.com"); + row.setNonce(NONCE); + row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET)); + row.setStatus(ConnectRequest.Status.PENDING); + row.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + return row; + } + + /** A re-authentication whose team came from the instance's credential, not from a browser. */ + private static ConnectRequest reauthPinnedTo(Long teamId) { + ConnectRequest row = pending(); + row.setMode(ConnectRequest.Mode.REAUTH); + row.setTeamId(teamId); + return row; + } + + private static ConnectRequest approved() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.APPROVED); + row.setTeamId(7L); + row.setApprovedByUserId(42L); + row.setApprovedAt(LocalDateTime.now()); + return row; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index da719e7ba5..4727d78463 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest { apiKeyAuthenticationService; private SupabaseSecurityConfig config(ApplicationProperties props) { + return config(props, new MockEnvironment()); + } + + /** Loopback CORS origins are only added outside production, so the environment decides. */ + private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) { return new SupabaseSecurityConfig( userService, teamService, supabaseUserService, saasTeamService, props, - apiKeyAuthenticationService); + apiKeyAuthenticationService, + environment); } @Nested @@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest { .hasSize(1); } + @Test + @DisplayName("production does not allow loopback on arbitrary ports") + void productionHasNoLoopbackWildcard() { + CorsConfiguration cfg = + cors(config(new ApplicationProperties()).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]"); + } + + @Test + @DisplayName("non-production allows loopback on any port so dev servers can move") + void devAllowsAnyLoopbackPort() { + // Several dev servers run side by side and their ports change; pinning a list turns + // every new local environment into an opaque CORS failure. + MockEnvironment dev = new MockEnvironment(); + dev.setActiveProfiles("saas", "dev"); + + CorsConfiguration cfg = + cors(config(new ApplicationProperties(), dev).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .contains("http://localhost:[*]", "http://127.0.0.1:[*]") + // Still credentialed, which is the reason the pattern form matters. + .contains("https://stirling.com"); + assertThat(cfg.getAllowCredentials()).isTrue(); + } + + @Test + @DisplayName("an operator origin list is respected verbatim even in dev") + void operatorOverrideSuppressesLoopbackWildcard() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com")); + MockEnvironment dev = new MockEnvironment(); + dev.setActiveProfiles("saas", "dev"); + + CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource()); + + // An operator who set the list meant it; we do not widen it behind their back. + assertThat(cfg.getAllowedOriginPatterns()) + .contains("https://custom.example.com") + .doesNotContain("http://localhost:[*]"); + } + @Test @DisplayName("operator override replaces the default origin list") void operatorOverrideUsed() { diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 540836032f..4af1be3359 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3240,7 +3240,7 @@ enterEmailConfirm = "To confirm deletion, please type your email address ({{emai guestDescription = "You are signed in as a guest. Consider upgrading your account above." label = "Overview" manageAccountPreferences = "Manage your account preferences" -signedInAs = "Signed in as" +signedInAs = "Account" title = "Account Settings" [config.account.profilePicture] @@ -3351,6 +3351,39 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" +[connect] +loading = "Checking this request." +redirecting = "Returning you to your server." + +[connect.confirm] +acknowledge = "I recognise this address and want to connect it to my team" +approve = "Connect server" +deny = "Decline" +lead = "A Stirling server is asking to connect to your team. Check the address below is yours before you approve." +originLabel = "Address" +signedInAs = "Signed in as" +switchAccount = "Use a different account" +title = "Connect this server?" +unknownAccount = "an unknown account" + +[connect.confirm.insecure] +body = "This address does not use HTTPS, so your sign-in will be sent over an unencrypted connection. Only approve it on a network you trust." +label = "Not an encrypted address" + +[connect.declined] +body = "Nothing was connected. You can close this page." +title = "Request declined" + +[connect.error] +failed = "That did not go through. Only a team owner can connect a server." + +[connect.meta] +title = "Connect a server" + +[connect.notFound] +body = "This connection request is not valid. It may have expired, or already been used. Start another one from your server." +title = "Request not valid" + [convert] autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" @@ -6487,6 +6520,34 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect.callback] +continue = "Continue" +linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." +modalTitle = "Connecting this server" +retry = "Try again" +signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." +working = "Finishing the connection." + +[portal.accountLink.connect.callback.expired] +body = "Connection requests are short lived. Start another one." +title = "Request expired" + +[portal.accountLink.connect.callback.linked] +body = "This server is connected to your Stirling account." +title = "Server connected" + +[portal.accountLink.connect.callback.malformed] +body = "This page was opened without a valid connection response. Start the connection from settings." +title = "Could not read the response" + +[portal.accountLink.connect.callback.rejected] +body = "This request was declined or has already been used. Start another one if that was not intended." +title = "Connection not completed" + +[portal.accountLink.connect.callback.unfinished] +body = "Stirling did not confirm the connection. This is usually temporary." +title = "Not finished yet" + [portal.accountLink.gate] action = "Link account" description = "Link this org's Stirling account to use billable features." @@ -6520,17 +6581,24 @@ minutesAgo_other = "{{count}}m ago" never = "never" [portal.accountLink.modal] -linkSubtitle = "Sign in to the account this server should bill against." -linkTitle = "Link your Stirling account" -reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked." +cancel = "Cancel" +continueLink = "Continue to Stirling" +continueReauth = "Sign in again" +linkSubtitle = "Connect this server to the Stirling account it should bill against." +linkTitle = "Connect your Stirling account" +noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." +reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" -simulateSignIn = "Simulate sign-in (dev)" +startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." +step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." +step2 = "You check this server's address and approve it. A team owner has to do this the first time." +step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] -after = "to enable in-app linking against the hosted Stirling account." +after = "so this server can finish the connection when you come back." and = "and" before = "Set" -title = "SaaS login not configured" +title = "Stirling connection not configured" [portal.accountLink.panel] instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access." diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index 35c9fc7e88..a82f1c7405 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -1,52 +1,24 @@ import { TierProvider } from "@portal/contexts/TierContext"; -import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import { LinkProvider } from "@portal/contexts/LinkContext"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; -import { - AccountLinkProvider, - useAccountLinkContext, -} from "@portal/contexts/AccountLinkContext"; +import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; +import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; -/** - * The one and only account-link login modal. Mounted at the app root (never - * nested in another overlay) and driven by UIContext, so any "Link account" CTA - * — sidebar, billing prompt, feature gate, Settings panel — opens this exact - * instance. Linking is finished by the shared {@link useAccountLinkContext} - * orchestration. - */ +/** The one and only account-link modal. */ function LinkModalHost() { const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); - const { markSaasSessionChanged } = useLink(); - const link = useAccountLinkContext(); - // "reauth" only refreshes the browser SaaS session for attended reads — the - // sign-in already applied it to the Supabase client, so we just signal a - // refetch. It must NOT call completeLink (that re-registers → duplicate row). - const onLinked = - linkModalMode === "reauth" - ? () => markSaasSessionChanged() - : (session: SupabaseLoginSession) => link.completeLink(session); return ( ); } -/** - * Self-hosted provider stack. The account-link layer (LinkProvider + - * AccountLinkProvider + the login modal) wraps the shared chrome; the tier is - * derived from the link/subscription state (see usePlanTier). TierProvider sits - * inside LinkProvider because the self-hosted usePlanTier reads useLink. - * - * The SaaS build shadows this file to drop the account-link layer entirely — the - * signed-in account IS the SaaS account, so there is nothing to link and the - * tier comes from the wallet. - */ +/** Self-hosted provider stack. */ export function PortalProviders() { return ( @@ -55,6 +27,7 @@ export function PortalProviders() { + diff --git a/frontend/editor/src/portal/api/link.test.ts b/frontend/editor/src/portal/api/link.test.ts index e03d5bf25b..9c05952549 100644 --- a/frontend/editor/src/portal/api/link.test.ts +++ b/frontend/editor/src/portal/api/link.test.ts @@ -34,7 +34,6 @@ import { fetchInstances, fetchLocalUsage, fetchStatus, - linkInstance, revokeInstance, unlinkInstance, } from "@portal/api/link"; @@ -55,21 +54,14 @@ describe("api/link — local backend (this instance)", () => { expect(status.linked).toBe(false); }); - it("links this instance via the local endpoint, never returning a secret", async () => { - const status = await linkInstance({ - supabaseJwt: "jwt_abc", - name: "node-1", - }); - expect(status.linked).toBe(true); - expect(status.name).toBe("node-1"); - // Contract: the device secret is stored server-side, never sent to the portal. + it("never exposes the device credential in a status read", async () => { + // Contract: the device secret is stored server-side and the portal never sees it. + const status = await fetchStatus(); expect(status).not.toHaveProperty("deviceSecret"); expect(status).not.toHaveProperty("deviceId"); - expect(await (await fetchStatus()).linked).toBe(true); }); it("unlinks this instance", async () => { - await linkInstance({ supabaseJwt: "jwt_abc" }); // unlink returns 204 (no body); the status is read back separately. await unlinkInstance(); expect((await fetchStatus()).linked).toBe(false); @@ -84,18 +76,6 @@ describe("api/link — local backend (this instance)", () => { ); expect(usage.totalUnsyncedUnits).toBeGreaterThanOrEqual(0); }); - - it("forwards the SaaS JWT in the link body", async () => { - let seenBody: unknown = null; - server.events.on("request:start", async ({ request }) => { - if (request.method === "POST" && request.url.endsWith("/link")) { - seenBody = await request.clone().json(); - } - }); - await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" }); - expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" }); - server.events.removeAllListeners(); - }); }); describe("api/link — SaaS backend (team-wide)", () => { diff --git a/frontend/editor/src/portal/api/link.ts b/frontend/editor/src/portal/api/link.ts index 696ccbe5b2..c3684b3dd2 100644 --- a/frontend/editor/src/portal/api/link.ts +++ b/frontend/editor/src/portal/api/link.ts @@ -1,13 +1,5 @@ import { apiClient } from "@portal/api/http"; -/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */ -export interface LinkInstanceRequest { - /** Admin's SaaS session JWT, obtained via the hosted-login popup. */ - supabaseJwt: string; - /** Optional label for this instance. */ - name?: string; -} - /** Link status for this instance (GET /api/v1/account-link/status). */ export interface LinkStatus { linked: boolean; @@ -15,12 +7,7 @@ export interface LinkStatus { name: string | null; } -/** - * Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). - * The portal adds this on top of the SaaS-synced spend so "current usage" - * includes work done since the last daily sync. Per-category unsynced units for - * the current period; all zero when metering is off or nothing is pending. - */ +/** Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). */ export interface LocalUsage { /** ISO timestamp of the current period start; null when unknown (not yet synced). */ periodStart: string | null; @@ -42,93 +29,88 @@ export interface LinkedInstanceRow { revoked: boolean; } -/** - * Account-link client (combined-billing "Mode A"). Two distinct surfaces: - * - * THIS instance — apiClient.local (Spring admin bearer auto-attached): - * - POST /api/v1/account-link/link — hand the local backend the admin's - * SaaS JWT in the body. It registers - * with SaaS + stores the device - * secret SERVER-SIDE; the portal - * NEVER receives or renders it. - * - GET /api/v1/account-link/status — Linked / Not-linked for this - * instance. - * - POST /api/v1/account-link/unlink — drop this instance's link (local - * backend best-effort tells SaaS). - * - * TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached - * from the in-app account-link login): - * - GET /api/v1/account-link/instances — every linked instance - * - POST /api/v1/account-link/instances/{id}/revoke - * - * The team-wide endpoints are served by the hosted SaaS Java backend (the - * local backend has no such routes), so they go through apiClient.saas. In - * Storybook/tests, wildcard MSW handlers match both the local and absolute - * SaaS URLs. - */ +/** Account-link client (combined billing). */ const BASE = "/api/v1/account-link"; -/** - * Link THIS instance. The local backend takes the SaaS JWT, registers with - * SaaS, and persists the device secret itself; the response carries only the - * resulting link status. No secret is returned. - */ -export async function linkInstance( - req: LinkInstanceRequest, -): Promise { - return apiClient.local.json(`${BASE}/link`, { - method: "POST", - body: req, - }); -} - /** Linked / Not-linked for this instance. */ export async function fetchStatus(): Promise { return apiClient.local.json(`${BASE}/status`); } /** - * Locally-accrued usage not yet reported to SaaS — the portal adds this on top - * of the SaaS-synced spend so "current usage" includes work done since the last - * daily sync. Local-backend call; returns zeros when metering is off. + * Locally-accrued usage not yet reported to SaaS — the portal adds this on top of the SaaS-synced spend so "current usage" includes work done since the last daily sync. */ export async function fetchLocalUsage(): Promise { return apiClient.local.json(`${BASE}/usage`); } -/** - * Drop this instance's link. The local backend best-effort tells SaaS to - * revoke before clearing the credential locally, then returns 204 — there's no - * body, so the caller sets the known unlinked status itself. - */ +/** Drop this instance's link. */ export async function unlinkInstance(): Promise { await apiClient.local.json(`${BASE}/unlink`, { method: "POST" }); } -/** - * Nudge the local backend to sync + refresh its cached entitlement now. Called - * right after a checkout completes so the instance's request-time gate reflects - * the new subscription immediately instead of waiting out its entitlement-cache - * TTL. Best-effort — the caller swallows failures (metering off → 409, or the - * local backend unreachable); the scheduled sync / TTL refresh is the backstop. - */ +/** Nudge the local backend to sync + refresh its cached entitlement now. */ export async function triggerLocalSync(): Promise { await apiClient.local.json(`${BASE}/sync-now`, { method: "POST" }); } +/** Where a browser-mediated connect handshake has got to. */ +export type ConnectPhase = + | "NONE" + | "PENDING" + | "LINKED" + | "EXPIRED" + | "REJECTED" + | "UNAVAILABLE"; + +export interface ConnectStatus { + phase: ConnectPhase; + /** Approval page to send the admin to. */ + authorizeUrl: string | null; + secondsRemaining: number | null; + teamId: number | null; +} + +const CONNECT = `${BASE}/connect`; + +/** Open a handshake and get the approval URL to send the admin to. */ +export async function startConnect( + name?: string, + callbackUrl?: string, +): Promise { + return apiClient.local.json(`${CONNECT}/start`, { + method: "POST", + body: { name, callbackUrl }, + }); +} + +/** Re-establish the SaaS session for a server that is already linked. */ +export async function startReauth( + callbackUrl?: string, +): Promise { + return apiClient.local.json(`${CONNECT}/reauth`, { + method: "POST", + body: { callbackUrl }, + }); +} + +/** Finish a handshake using the nonce the approval page put in the callback fragment. */ +export async function completeConnect(nonce: string): Promise { + return apiClient.local.json(`${CONNECT}/complete`, { + method: "POST", + body: { nonce }, + }); +} + /** - * Every linked instance for the team — SaaS-direct call with the admin's - * Supabase JWT (no longer takes an accessToken parameter; the saas client - * resolves the live session itself). + * Every linked instance for the team — SaaS-direct call with the admin's Supabase JWT (no longer takes an accessToken parameter; the saas client resolves the live session itself). */ export async function fetchInstances(): Promise { return apiClient.saas.json(`${BASE}/instances`); } -/** - * Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. - */ +/** Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. */ export async function revokeInstance(instanceId: number): Promise { await apiClient.saas.json(`${BASE}/instances/${instanceId}/revoke`, { method: "POST", diff --git a/frontend/editor/src/portal/auth/saasSupabase.ts b/frontend/editor/src/portal/auth/saasSupabase.ts index 1005d63a63..45598c369b 100644 --- a/frontend/editor/src/portal/auth/saasSupabase.ts +++ b/frontend/editor/src/portal/auth/saasSupabase.ts @@ -22,11 +22,12 @@ const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; export const isSaasSupabaseConfigured = Boolean(url && key); -/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */ -export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"]; - -/** sessionStorage marker set before an SSO redirect so the return can finish the link. */ -export const PENDING_LINK_KEY = "stirling-account-link-pending"; +/* + * SAAS_OAUTH_PROVIDERS and PENDING_LINK_KEY are gone. They served an in-portal SSO sign-in that + * could not work: the provider only redirects to allow-listed URLs, so a customer's origin was + * never returned to and the admin was left on stirling.com. Provider choice now happens on our own + * origin during the connect handshake, where the redirect can actually complete. + */ let configured = false; diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx new file mode 100644 index 0000000000..fab501da4a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Modal } from "@app/ui"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { withBasePath } from "@app/constants/app"; +import { + completeConnect, + startConnect, + type ConnectPhase, +} from "@portal/api/link"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; +import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; +import { + ConnectCallbackView, + type ConnectCallbackState, +} from "@portal/components/account-link/ConnectCallbackView"; +import "@portal/views/ConnectCallback.css"; + +/** What the callback route hands over, read from the URL fragment before stripping it. */ +export interface AccountLinkReturn { + type: string | null; + nonce: string | null; + accessToken: string | null; + refreshToken: string | null; +} + +interface LocationState { + accountLinkReturn?: AccountLinkReturn; +} + +/** + * Finishes the handshake and reports the outcome, over the portal the admin + * started from. + * + * Mounted alongside the other portal-wide modal rather than being its own route: + * the result is a step in a task, so the page behind it should still be there. + */ +export function ConnectCallbackHost() { + const location = useLocation(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { refresh } = useAccountLinkContext(); + const handover = (location.state as LocationState | null)?.accountLinkReturn; + + const [state, setState] = useState(null); + const [sessionRestored, setSessionRestored] = useState(false); + const nonceRef = useRef(null); + const startedRef = useRef(false); + + const finish = useCallback( + async (nonce: string) => { + setState("working"); + try { + const outcome = toViewState((await completeConnect(nonce)).phase); + setState(outcome); + // The portal read its status on mount, before this existed. Without this + // the page behind the modal still says unlinked until a reload. + if (outcome === "linked") await refresh(); + } catch { + // Could not reach our own backend. The handshake is still open, so this + // is worth another attempt rather than a restart. + setState("retry"); + } + }, + [refresh], + ); + + useEffect(() => { + if (!handover || startedRef.current) return; + startedRef.current = true; + + const { type, nonce, accessToken, refreshToken } = handover; + if (type !== "link" || !nonce) { + setState("malformed"); + return; + } + nonceRef.current = nonce; + + void (async () => { + if (accessToken && refreshToken) { + try { + const supabase = ensureSaasSupabase(); + // Logged, not swallowed: silently this resurfaces later as "session + // expired" on the usage page, with nothing tying it back here. + if (!supabase) { + console.warn( + "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", + ); + } else { + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }); + if (error) { + console.warn("[account-link] setSession failed:", error.message); + } else { + setSessionRestored(true); + } + } + } catch (e) { + console.warn("[account-link] session hand-off threw:", e); + } + } else { + console.warn( + "[account-link] callback carried no tokens; the approval page had no session to pass", + ); + } + await finish(nonce); + })(); + }, [handover, finish]); + + /** + * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. + */ + const onRetry = useCallback(() => { + if (state === "retry" && nonceRef.current) { + void finish(nonceRef.current); + return; + } + setState("working"); + // Same callback the modal sends. Without it the backend falls back to the bare + // origin, which drops the app's base path and lands the return on nothing. + void startConnect( + window.location.hostname, + new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(), + ) + .then((status) => { + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + } else { + setState("rejected"); + } + }) + .catch(() => setState("retry")); + }, [state, finish]); + + // Drops the handover with it, so a back navigation does not reopen the result. + const done = useCallback(() => { + setState(null); + navigate(PORTAL_BASENAME, { replace: true }); + }, [navigate]); + + if (!state) return null; + + return ( + + + + ); +} + +/** + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + */ +function toViewState(phase: ConnectPhase): ConnectCallbackState { + switch (phase) { + case "LINKED": + return "linked"; + case "EXPIRED": + return "expired"; + case "PENDING": + case "UNAVAILABLE": + return "retry"; + default: + return "rejected"; + } +} diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx new file mode 100644 index 0000000000..4a22592e45 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from "react-i18next"; +import { Banner, Button, Spinner } from "@app/ui"; + +/** Outcomes of returning from the approval page. */ +export type ConnectCallbackState = + | "working" + | "linked" + | "retry" + | "expired" + | "rejected" + | "malformed"; + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + /** True once the SaaS session landed, regardless of how the link itself went. */ + sessionRestored: boolean; + onRetry: () => void; + onDone: () => void; +} + +/** Presentation for the account-link callback. */ +export function ConnectCallbackView({ + state, + sessionRestored, + onRetry, + onDone, +}: ConnectCallbackViewProps) { + const { t } = useTranslation(); + + if (state === "working") { + return ( +

+ +

+ {t( + "portal.accountLink.connect.callback.working", + "Finishing the connection.", + )} +

+
+ ); + } + + if (state === "linked") { + return ( +
+ + {t( + "portal.accountLink.connect.callback.linked.body", + "This server is connected to your Stirling account.", + )} + + {/* The inverse of the failure note below: the link took but the sign-in did + not, which otherwise only shows up later as "session expired" on a page + that gives no hint the two are related. */} + {sessionRestored ? null : ( +

+ {t( + "portal.accountLink.connect.callback.linkedNotSignedIn", + "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in.", + )} +

+ )} + +
+ ); + } + + const { tone, title, body, retryable } = failure(state, t); + return ( +
+ + {body} + + {/* The SaaS sign-in and the server link are separate outcomes. Say so when + one worked and the other did not, or the admin re-runs the whole thing + to fix a problem that is already half solved. */} + {sessionRestored ? ( +

+ {t( + "portal.accountLink.connect.callback.signedInAnyway", + "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete.", + )} +

+ ) : null} + +
+ ); +} + +type Translate = ReturnType["t"]; + +function failure(state: ConnectCallbackState, t: Translate) { + switch (state) { + case "expired": + return { + tone: "warning" as const, + title: t( + "portal.accountLink.connect.callback.expired.title", + "Request expired", + ), + body: t( + "portal.accountLink.connect.callback.expired.body", + "Connection requests are short lived. Start another one.", + ), + retryable: true, + }; + case "rejected": + return { + tone: "warning" as const, + title: t( + "portal.accountLink.connect.callback.rejected.title", + "Connection not completed", + ), + body: t( + "portal.accountLink.connect.callback.rejected.body", + "This request was declined or has already been used. Start another one if that was not intended.", + ), + retryable: true, + }; + case "malformed": + return { + tone: "danger" as const, + title: t( + "portal.accountLink.connect.callback.malformed.title", + "Could not read the response", + ), + body: t( + "portal.accountLink.connect.callback.malformed.body", + "This page was opened without a valid connection response. Start the connection from settings.", + ), + retryable: false, + }; + default: + return { + tone: "warning" as const, + // Not "retry.*": that key is the button label, and TOML cannot hold a + // value and a table under the same name. + title: t( + "portal.accountLink.connect.callback.unfinished.title", + "Not finished yet", + ), + body: t( + "portal.accountLink.connect.callback.unfinished.body", + "Stirling did not confirm the connection. This is usually temporary.", + ), + retryable: true, + }; + } +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx index af949e9dc9..f20f777280 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx @@ -9,8 +9,9 @@ const base: UseAccountLink = { status: { linked: false, name: null }, phase: "idle", error: null, - completeLink: async () => {}, + unlink: async () => {}, + refresh: async () => {}, }; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css new file mode 100644 index 0000000000..bc96172f0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css @@ -0,0 +1,26 @@ +/* Connect-account modal. Imported by the component rather than relying on the + account-link view's stylesheet: this modal is mounted at the app root, so it + renders on pages that never import that view. */ + +.portal-link__modal-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-link__steps { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding-left: 1.25rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--c-text-muted); +} + +.portal-link__modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx index 8228e3b1bd..b7ba6647bf 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx @@ -8,16 +8,19 @@ const meta: Meta = { args: { open: true, onClose: () => {}, - onLinked: async () => {}, }, }; export default meta; type Story = StoryObj; -/** Default "link" mode — sign in to register this instance against a Stirling account. */ +/** + * "link" mode — explains the trip to Stirling and starts the handshake. There is no + * sign-in form: a sign-in started on a self-hosted origin cannot complete, because + * the provider will not redirect back to a hostname it does not know. + */ export const Default: Story = {}; -/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */ +/** "reauth" mode — the server stays linked; only the browser session is renewed. */ export const Reauth: Story = { args: { mode: "reauth" }, }; diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx new file mode 100644 index 0000000000..018d435b31 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +/** The modal every "link account" CTA in the portal opens. */ +const { startConnect, startReauth } = vi.hoisted(() => ({ + startConnect: vi.fn(), + startReauth: vi.fn(), +})); + +vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/auth/saasSupabase", () => ({ + isSaasSupabaseConfigured: true, +})); + +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; + +const AUTHORIZE = "http://localhost:5174/link?request=req-1"; + +function renderModal(mode?: "link" | "reauth") { + return render( + + {}} mode={mode} /> + , + ); +} + +/** Clicks the primary action (the secondary one is Cancel). */ +function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { + const buttons = getAllByRole("button"); + act(() => buttons[buttons.length - 1].click()); +} + +describe("LinkAccountModal", () => { + let assign: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + startConnect.mockResolvedValue({ + phase: "PENDING", + authorizeUrl: AUTHORIZE, + secondsRemaining: 900, + teamId: null, + }); + startReauth.mockResolvedValue({ + phase: "PENDING", + authorizeUrl: AUTHORIZE, + secondsRemaining: 900, + teamId: null, + }); + assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + origin: "http://localhost:5173", + hostname: "localhost", + assign, + }, + }); + }); + + it("offers no sign-in form, because a sign-in started here cannot complete", () => { + const { container } = renderModal(); + + // The provider buttons this modal used to carry sent the admin to Stirling and + // abandoned them there. Nothing should collect credentials on this origin. + expect(container.querySelector("input[type=password]")).toBeNull(); + expect(container.querySelector("input[type=email]")).toBeNull(); + }); + + it("starts a link handshake and hands the browser to Stirling", async () => { + const { getAllByRole } = renderModal(); + + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Callback built from this page's own origin, which the backend then checks + // against the request's Origin header. + expect(startConnect).toHaveBeenCalledWith( + "localhost", + "http://localhost:5173/account-link/callback", + ); + await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); + expect(startReauth).not.toHaveBeenCalled(); + }); + + it("uses the reauth endpoint when only the session needs renewing", async () => { + const { getAllByRole } = renderModal("reauth"); + + clickContinue(getAllByRole); + + // A different endpoint on purpose: reauth presents the device credential so + // Stirling pins the handshake to the team that already owns this server. + await waitFor(() => + expect(startReauth).toHaveBeenCalledWith( + "http://localhost:5173/account-link/callback", + ), + ); + expect(startConnect).not.toHaveBeenCalled(); + await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); + }); + + it("stays put and explains itself when the handshake cannot start", async () => { + startConnect.mockRejectedValue(new Error("offline")); + + const { getAllByRole } = renderModal(); + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); + + it("does not navigate when there is nothing to navigate to", async () => { + // Already linked: the backend reports status without an authorize URL. + startConnect.mockResolvedValue({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); + + const { getAllByRole } = renderModal(); + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index e46504d309..8bd47a9eb4 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,63 +1,60 @@ -import { useEffect } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Modal } from "@app/ui"; -import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm"; -import { - useSupabaseLogin, - type SupabaseLoginSession, -} from "@app/auth/ui/useSupabaseLogin"; -import "@app/auth/ui/auth-theme.css"; -import { - ensureSaasSupabase, - isSaasSupabaseConfigured, - PENDING_LINK_KEY, - SAAS_OAUTH_PROVIDERS, -} from "@portal/auth/saasSupabase"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import "@portal/components/account-link/LinkAccountModal.css"; interface Props { open: boolean; onClose: () => void; /** - * "link" registers this instance against the signed-in account; "reauth" only - * refreshes an expired SaaS session (the instance is already linked). The mode - * is persisted across the OAuth redirect so the SSO-return handler doesn't - * re-register on a reauth. + * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. */ mode?: "link" | "reauth"; - /** Called with the SaaS session after a successful sign-in. */ - onLinked: (session: SupabaseLoginSession) => void | Promise; } -/** - * In-app account-link login. Signs the admin in to their Stirling (SaaS) account - * via the shared Supabase login (SSO + email/password), then hands the resulting - * session to the caller to register this instance. No popup; the device secret - * never reaches the browser. SSO redirects away and is finished by useAccountLink - * on return. - */ -export function LinkAccountModal({ - open, - onClose, - mode = "link", - onLinked, -}: Props) { +/** Sends the admin off to Stirling to connect this server. */ +export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { const { t } = useTranslation(); - useEffect(() => { - if (open) ensureSaasSupabase(); - }, [open]); - const reauth = mode === "reauth"; - const login = useSupabaseLogin({ - providers: SAAS_OAUTH_PROVIDERS, - // Return to the current page after SSO; the SSO-return handler in - // useAccountLink reads the persisted mode so it links vs. only refreshes. - redirectTo: window.location.href, - onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode), - onSuccess: async (session) => { - await onLinked(session); - onClose(); - }, - }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const begin = useCallback(async () => { + setBusy(true); + setError(null); + try { + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + } finally { + setBusy(false); + } + }, [reauth, t]); return ( - {isSaasSupabaseConfigured ? ( - - ) : ( -
+
+
    +
  1. + {t( + "portal.accountLink.modal.step1", + "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", + )} +
  2. +
  3. + {t( + "portal.accountLink.modal.step2", + "You check this server's address and approve it. A team owner has to do this the first time.", + )} +
  4. +
  5. + {t( + "portal.accountLink.modal.step3", + "Stirling brings you straight back here and finishes up.", + )} +
  6. +
+ + {!isSaasSupabaseConfigured && ( {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} @@ -101,25 +117,27 @@ export function LinkAccountModal({ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} {t( "portal.accountLink.modal.loginNotConfigured.after", - "to enable in-app linking against the hosted Stirling account.", + "so this server can finish the connection when you come back.", )} - {import.meta.env.DEV && ( - - )} + )} + + {error && {error}} + +
+ +
- )} +
); } diff --git a/frontend/editor/src/portal/contexts/AccountLinkContext.tsx b/frontend/editor/src/portal/contexts/AccountLinkContext.tsx index d3f4bf3678..0ab5fc4da7 100644 --- a/frontend/editor/src/portal/contexts/AccountLinkContext.tsx +++ b/frontend/editor/src/portal/contexts/AccountLinkContext.tsx @@ -5,15 +5,7 @@ import { } from "@portal/hooks/useAccountLink"; /** - * Single app-wide {@link useAccountLink} instance. The link flow is orchestrated - * in exactly one place so that: - * - status is fetched once on mount (not per consumer), and - * - the SSO-return effect fires once — two instances would both call - * {@link UseAccountLink.completeLink} on return and re-register the device - * credential, leaving a duplicate linked_instance row. - * - * Consumers (the top-level link modal host, the Settings account-link panel, - * the link card) read this shared instance instead of calling the hook again. + * Single app-wide {@link useAccountLink} instance, so status is fetched once on mount rather than per consumer. */ const AccountLinkContext = createContext(null); diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index d088647586..7fd7cfe536 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -8,7 +8,7 @@ import { } from "react"; /** - * The "linked" dimension of the account-link surface (combined-billing "Mode A"), + * The "linked" dimension of the account-link surface (combined billing), * a sibling to TierContext. It answers one question the rest of the portal asks: * has this self-hosted org linked its SaaS account, and if so, is it on the free * grant or actively subscribed? @@ -59,13 +59,6 @@ interface LinkContextValue { isLinked: boolean; /** Convenience for `LINK_INFO[linkState].unlocked` — billable features usable. */ featuresUnlocked: boolean; - /** - * Bumps whenever the browser's SaaS session changes (e.g. a re-sign-in after - * expiry). Attended SaaS reads (the wallet) key off this to refetch with the - * fresh token without re-establishing the instance link. - */ - saasSessionNonce: number; - markSaasSessionChanged: () => void; } const LinkContext = createContext(null); @@ -78,11 +71,6 @@ export function LinkProvider({ initialState?: LinkState; }) { const [linkState, setLinkState] = useState(initialState); - const [saasSessionNonce, setSaasSessionNonce] = useState(0); - const markSaasSessionChanged = useCallback( - () => setSaasSessionNonce((n) => n + 1), - [], - ); const value = useMemo(() => { const unlocked = LINK_INFO[linkState].unlocked; return { @@ -90,10 +78,8 @@ export function LinkProvider({ setLinkState, isLinked: linkState !== "unlinked", featuresUnlocked: unlocked, - saasSessionNonce, - markSaasSessionChanged, }; - }, [linkState, saasSessionNonce, markSaasSessionChanged]); + }, [linkState]); return {children}; } diff --git a/frontend/editor/src/portal/hooks/useAccountLink.test.tsx b/frontend/editor/src/portal/hooks/useAccountLink.test.tsx deleted file mode 100644 index b327322778..0000000000 --- a/frontend/editor/src/portal/hooks/useAccountLink.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { LinkProvider } from "@portal/contexts/LinkContext"; - -/** - * The SSO-return path is mode-aware: a "reauth" return must only refresh the - * session, NOT re-register the instance (re-registering mints a duplicate device - * credential). This is the exact regression that slipped through once, so it gets - * a dedicated guard. - */ -const { linkInstance, fetchStatus, unlinkInstance, getSession } = vi.hoisted( - () => ({ - linkInstance: vi.fn(), - fetchStatus: vi.fn(), - unlinkInstance: vi.fn(), - getSession: vi.fn(), - }), -); - -vi.mock("@portal/api/link", () => ({ - linkInstance, - fetchStatus, - unlinkInstance, -})); -vi.mock("@portal/auth/saasSupabase", () => ({ - PENDING_LINK_KEY: "stirling_pending_link", - isSaasSupabaseConfigured: true, - SAAS_OAUTH_PROVIDERS: [], - ensureSaasSupabase: () => ({ auth: { getSession } }), -})); - -import { useAccountLink } from "@portal/hooks/useAccountLink"; -import { PENDING_LINK_KEY } from "@portal/auth/saasSupabase"; - -function Probe() { - useAccountLink(); - return null; -} - -const renderHook = () => - render( - - - , - ); - -beforeEach(() => { - linkInstance.mockReset().mockResolvedValue({ linked: true, name: null }); - fetchStatus.mockReset().mockResolvedValue({ linked: true, name: null }); - unlinkInstance.mockReset(); - getSession.mockReset().mockResolvedValue({ - data: { session: { access_token: "tok" } }, - }); - sessionStorage.clear(); -}); -afterEach(() => sessionStorage.clear()); - -describe("useAccountLink — SSO return", () => { - it("reauth mode refreshes the session WITHOUT re-registering", async () => { - sessionStorage.setItem(PENDING_LINK_KEY, "reauth"); - renderHook(); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(linkInstance).not.toHaveBeenCalled(); - }); - - it("link mode registers the instance with the returned token", async () => { - sessionStorage.setItem(PENDING_LINK_KEY, "link"); - renderHook(); - await waitFor(() => expect(linkInstance).toHaveBeenCalledTimes(1)); - expect(linkInstance.mock.calls[0][0].supabaseJwt).toBe("tok"); - }); -}); diff --git a/frontend/editor/src/portal/hooks/useAccountLink.ts b/frontend/editor/src/portal/hooks/useAccountLink.ts index dd4894f6d6..3e47df5220 100644 --- a/frontend/editor/src/portal/hooks/useAccountLink.ts +++ b/frontend/editor/src/portal/hooks/useAccountLink.ts @@ -1,33 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; -import { - ensureSaasSupabase, - isSaasSupabaseConfigured, - PENDING_LINK_KEY, -} from "@portal/auth/saasSupabase"; -import { - fetchStatus, - linkInstance, - unlinkInstance, - type LinkStatus, -} from "@portal/api/link"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { fetchStatus, unlinkInstance, type LinkStatus } from "@portal/api/link"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; -/** - * Orchestrates the account-link flow for THIS instance: - * - * 1. The admin signs in to their Stirling account IN-APP (LinkAccountModal → - * shared Supabase login), minting a short-term SaaS JWT. - * 2. {@link completeLink} POSTs that JWT to the LOCAL backend (api/link.ts), - * which registers with SaaS and stores the device secret server-side. - * 3. The resulting Linked / Not-linked status is read back. - * - * Email/password resolves inline (the modal calls completeLink). SSO redirects - * the browser to the provider and back; the returned session is finished here on - * mount (see the pending-link effect). The device secret is never received or - * rendered. Subscription state is resolved separately from the wallet, so a fresh - * link marks the org linked-free. - */ +/** Reads and clears THIS instance's link status. */ export type LinkPhase = "idle" | "linking" | "error"; @@ -38,84 +14,32 @@ export interface UseAccountLink { status: LinkStatus | null; phase: LinkPhase; error: string | null; - /** Finish linking THIS instance with a SaaS session minted by the login modal. */ - completeLink: (session: SupabaseLoginSession, name?: string) => Promise; /** Unlink this instance. */ unlink: () => Promise; + /** Re-read the status, for when something outside this hook changed it. */ + refresh: () => Promise; } export function useAccountLink(): UseAccountLink { const applyLinkFacts = useApplyLinkFacts(); - const { markSaasSessionChanged } = useLink(); const [status, setStatus] = useState(null); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); - const completeLink = useCallback( - async (session: SupabaseLoginSession, name?: string) => { - setPhase("linking"); - setError(null); - try { - const next = await linkInstance({ - supabaseJwt: session.access_token, - name, - }); - setStatus(next); - setPhase("idle"); - if (next.linked) applyLinkFacts(true, false); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setPhase("error"); - } - }, - [applyLinkFacts], - ); - - // Read the current link status on mount. - useEffect(() => { - let cancelled = false; - void fetchStatus() - .then((s) => { - if (!cancelled) { - setStatus(s); - // A linked instance is at least linked-free; subscription comes from the wallet. - if (s.linked) applyLinkFacts(true, false); - } - }) - .catch(() => { - // Status endpoint absent (flag off) / unreachable → leave status null, - // which renders as "Not linked". Don't surface an error or leak an - // unhandled rejection for the expected flag-off case. - if (!cancelled) setStatus({ linked: false, name: null }); - }); - return () => { - cancelled = true; - }; + const refresh = useCallback(async () => { + try { + const s = await fetchStatus(); + setStatus(s); + // A linked instance is at least linked-free; subscription comes from the wallet. + if (s.linked) applyLinkFacts(true, false); + } catch { + setStatus({ linked: false, name: null }); + } }, [applyLinkFacts]); - // SSO return: an SSO sign-in we kicked off has redirected back and the SaaS - // session is now in the shared Supabase client. The pending marker carries the - // mode: "reauth" only refreshes attended reads (the instance is already linked - // — re-registering would mint a duplicate credential); anything else links. useEffect(() => { - const supabase = ensureSaasSupabase(); - const pending = sessionStorage.getItem(PENDING_LINK_KEY); - if (!supabase || pending === null) return; - let cancelled = false; - void supabase.auth.getSession().then(({ data }) => { - sessionStorage.removeItem(PENDING_LINK_KEY); - const token = data.session?.access_token; - if (!token || cancelled) return; - if (pending === "reauth") { - markSaasSessionChanged(); - } else { - void completeLink({ access_token: token }); - } - }); - return () => { - cancelled = true; - }; - }, [completeLink, markSaasSessionChanged]); + void refresh(); + }, [refresh]); const unlink = useCallback(async () => { setPhase("linking"); @@ -136,7 +60,7 @@ export function useAccountLink(): UseAccountLink { status, phase, error, - completeLink, unlink, + refresh, }; } diff --git a/frontend/editor/src/portal/mocks/handlers/link.ts b/frontend/editor/src/portal/mocks/handlers/link.ts index 1724ce375f..bd7e6844f2 100644 --- a/frontend/editor/src/portal/mocks/handlers/link.ts +++ b/frontend/editor/src/portal/mocks/handlers/link.ts @@ -1,5 +1,4 @@ import { http, HttpResponse, delay } from "msw"; -import type { LinkInstanceRequest } from "@portal/api/link"; import { getLocalStatus, getLocalUsage, @@ -12,14 +11,13 @@ import { /** * Account-link MSW handlers. Two surfaces: * - * - LOCAL backend (this instance): link / status / unlink. `link` mutates the - * in-memory store and flips local status so the surface behaves like a real - * backend within a session. The device secret stays server-side — never - * returned over the wire, matching the real contract. + * - LOCAL backend (this instance): the connect handshake, status and unlink. + * `connect/complete` mutates the in-memory store and flips local status so the + * surface behaves like a real backend within a session. The device secret stays + * server-side — never returned over the wire, matching the real contract. * - SaaS backend (team-wide): instances / revoke. * - * Mirrors the real AccountLinkController paths so MSW can be dropped with no code - * change. + * Mirrors the real controller paths so MSW can be dropped with no code change. */ export const linkHandlers = [ http.get("/api/v1/account-link/status", async () => { @@ -27,15 +25,38 @@ export const linkHandlers = [ return HttpResponse.json(getLocalStatus()); }), - http.post("/api/v1/account-link/link", async ({ request }) => { + // Opening a handshake hands back where to send the admin. The real backend gets + // that URL from SaaS rather than composing it, so the mock returns one too. + http.post("*/api/v1/account-link/connect/start", async () => { await delay(120); - let name: string | undefined; - try { - name = ((await request.json()) as LinkInstanceRequest)?.name; - } catch { - // empty body — name stays undefined - } - return HttpResponse.json(linkLocal(name), { status: 201 }); + return HttpResponse.json({ + phase: "PENDING", + authorizeUrl: "https://app.stirling.test/link?request=mock-request", + secondsRemaining: 900, + teamId: null, + }); + }), + + http.post("*/api/v1/account-link/connect/reauth", async () => { + await delay(120); + return HttpResponse.json({ + phase: "PENDING", + authorizeUrl: "https://app.stirling.test/link?request=mock-reauth", + secondsRemaining: 900, + teamId: null, + }); + }), + + // The callback's completion step. Flips the store to linked, as a real claim would. + http.post("*/api/v1/account-link/connect/complete", async () => { + await delay(120); + linkLocal("mock-server"); + return HttpResponse.json({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); }), http.get("/api/v1/account-link/usage", async () => { diff --git a/frontend/editor/src/portal/mocks/link.ts b/frontend/editor/src/portal/mocks/link.ts index 92b4b90f2c..eb29ef5ba4 100644 --- a/frontend/editor/src/portal/mocks/link.ts +++ b/frontend/editor/src/portal/mocks/link.ts @@ -2,7 +2,7 @@ * Account-link fixtures. Types live in api/link.ts (the backend contract); * this module only builds fake data for Storybook and tests. * - * "Mode A" combined billing: a self-hosted instance links the org's SaaS account + * Combined billing: a self-hosted instance links the org's SaaS account * so its unattended calls bill against the org wallet. Two surfaces: * * - THIS instance: the local backend (`POST /api/v1/account-link/link`, diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css new file mode 100644 index 0000000000..35e9b09ef7 --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -0,0 +1,32 @@ +/* Account-link callback. A transient page the admin passes through, so it is + centred and says one thing rather than trying to be a settings screen. */ + +.portal-connect-callback { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + max-width: 30rem; + margin: 4rem auto; + padding: 0 1rem; + text-align: center; +} + +.portal-connect-callback > * { + width: 100%; +} + +/* The button is the one thing that should not stretch to the banner's width. */ +.portal-connect-callback button { + width: auto; +} + +.portal-connect-callback p { + margin: 0; + font-size: 0.875rem; + color: var(--c-text-muted); +} + +.portal-connect-callback__note { + font-size: 0.8125rem; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx new file mode 100644 index 0000000000..bdadee03e4 --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; + +/** + * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. + */ +const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( + () => ({ + completeConnect: vi.fn(), + startConnect: vi.fn(), + setSession: vi.fn(), + refresh: vi.fn(), + }), +); + +vi.mock("@portal/api/link", () => ({ completeConnect, startConnect })); +vi.mock("@portal/auth/saasSupabase", () => ({ + ensureSaasSupabase: () => ({ auth: { setSession } }), +})); +vi.mock("@portal/contexts/AccountLinkContext", () => ({ + useAccountLinkContext: () => ({ refresh }), +})); + +import ConnectCallback from "@portal/views/ConnectCallback"; +import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; + +const NONCE = "the-nonce"; + +function landOn(fragment: string) { + window.history.replaceState(null, "", `/account-link/callback${fragment}`); +} + +/** + * Route and host together: the route reads the fragment, the portal renders the + * outcome. Exercising them apart would test the hand-off rather than the flow. + */ +function renderFlow() { + return render( + + + + + } /> + } /> + + + , + ); +} + +describe("account-link callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + completeConnect.mockResolvedValue({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); + setSession.mockResolvedValue({ error: null }); + }); + + it("removes the token-bearing fragment from the URL", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + renderFlow(); + + // Synchronous, before any await: the fragment must not survive long enough + // to be read from the address bar or land in a history entry. + expect(window.location.hash).toBe(""); + await waitFor(() => expect(completeConnect).toHaveBeenCalled()); + }); + + it("lands on the portal rather than leaving the result on a bare page", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { getByTestId } = renderFlow(); + + await waitFor(() => expect(getByTestId("portal")).toBeTruthy()); + }); + + it("re-reads the link status, so the page behind agrees with the modal", async () => { + landOn(`#type=link&nonce=${NONCE}`); + + renderFlow(); + + await waitFor(() => expect(refresh).toHaveBeenCalled()); + }); + + it("deposits the session and then finishes the link with the nonce", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + renderFlow(); + + await waitFor(() => + expect(setSession).toHaveBeenCalledWith({ + access_token: "at", + refresh_token: "rt", + }), + ); + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + }); + + it("finishes the link even when the session hand-off fails", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + setSession.mockRejectedValue(new Error("nope")); + + renderFlow(); + + // The two outcomes are independent: a failed sign-in must not strand the + // server unlinked. + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + }); + + it("links without a session when the fragment carries no tokens", async () => { + landOn(`#type=link&nonce=${NONCE}`); + + renderFlow(); + + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("refuses a fragment with no nonce", async () => { + landOn("#type=link&access_token=at&refresh_token=rt"); + + renderFlow(); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(completeConnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("refuses a fragment that is not a link response", async () => { + landOn(`#type=something-else&nonce=${NONCE}&access_token=at`); + + renderFlow(); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(completeConnect).not.toHaveBeenCalled(); + }); + + it("refuses a bare page load", async () => { + landOn(""); + + renderFlow(); + + expect(completeConnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("offers a retry rather than a restart while the handshake is still open", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "UNAVAILABLE", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + const { getAllByRole } = renderFlow(); + + await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); + // Last button, not the only one: the modal shell contributes a close button. + const buttons = getAllByRole("button"); + act(() => buttons[buttons.length - 1].click()); + + // Retries the existing handshake; starting a new one would waste the + // approval a human just gave. + await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); + expect(startConnect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/ConnectCallback.tsx b/frontend/editor/src/portal/views/ConnectCallback.tsx new file mode 100644 index 0000000000..2fecd5244e --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import type { AccountLinkReturn } from "@portal/components/account-link/ConnectCallbackHost"; + +/** + * Return leg of the account-link handshake. Stirling redirects here with the + * admin's session in the URL fragment. + * + * This route only reads the fragment and hands it to the portal, which owns the + * rest. Rendering the outcome here would put it on an empty page; the portal is + * where the admin started, so that is where the result belongs. + */ +export default function ConnectCallback() { + const navigate = useNavigate(); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + const params = new URLSearchParams(window.location.hash.replace(/^#/, "")); + // Before anything else: the fragment carries a live session token. + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}`, + ); + + const accountLinkReturn: AccountLinkReturn = { + type: params.get("type"), + nonce: params.get("nonce"), + accessToken: params.get("access_token"), + refreshToken: params.get("refresh_token"), + }; + // Router state, not the URL: the tokens are live and must not be re-shareable. + navigate(PORTAL_BASENAME, { replace: true, state: { accountLinkReturn } }); + }, [navigate]); + + return null; +} diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 7f60fbdd3b..22ce46f03c 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -3,13 +3,6 @@ import type { ReactElement } from "react"; import { Route } from "react-router-dom"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -// The portal ships as a lazy chunk of the editor. It's included in dev (so it's -// always available to work on) and in production builds made with -// VITE_INCLUDE_PORTAL=true (set by -PbuildWithPortal in the JAR, and by the deploy -// GHA when the portal or AI layers change). Vite replaces the env with a literal at -// build time, so when it's off the dynamic import below is tree-shaken out and the -// portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on -// each render. const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; @@ -21,18 +14,28 @@ const PortalApp = includePortal : null; /** - * The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*). - * Access is gated inside PortalApp (its own AuthProvider + AuthGate, plus server - * enforcement), so this just wires the lazy route into the editor's router when - * the portal is included in this build. + * Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment. */ +const ConnectCallback = includePortal + ? lazy(async () => { + const m = await import("@portal/views/ConnectCallback"); + return { default: m.default }; + }) + : null; + +/** The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*). */ export function getAdminRouteExtensions(): ReactElement[] { - if (!PortalApp) return []; + if (!PortalApp || !ConnectCallback) return []; return [ } />, + } + />, ]; } diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 67dce869fd..2e091ebf05 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -11,10 +11,12 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import OnboardingTour from "@app/components/onboarding/OnboardingTour"; import Landing from "@app/routes/Landing"; import Login from "@app/routes/Login"; +import { ResumePendingConnect } from "@app/routes/ResumePendingConnect"; import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OAuthConsent from "@app/routes/OAuthConsent"; +import ConnectApprove from "@app/routes/ConnectApprove"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; @@ -110,12 +112,17 @@ export default function App() { > + } /> } /> } /> } /> } /> + {/* Human half of the self-hosted account-link handshake. It + lives on this origin because a customer hostname can + never be in the provider's redirect allow-list. */} + } /> {/* Shared-file links. Team invites are NOT routed here: on SaaS they are accepted in-app via the Supabase team invitation banner, not the Spring password-based diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index 8fbe925ff2..b69619b85d 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -4,6 +4,7 @@ import { resolveLandingPath } from "@app/utils/loginLanding"; import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; +import { readPendingConnect } from "@app/routes/pendingConnect"; import { AuthShell } from "@app/auth/ui/AuthShell"; import ErrorMessage from "@app/auth/ui/ErrorMessage"; import { Spinner } from "@app/ui/Spinner"; @@ -133,10 +134,20 @@ export default function AuthCallback() { // URL can't bounce the user off-origin after sign-in. // No explicit destination: land team leads on the processor and everyone // else on the editor. + // Explicit `next` first, so a sign-in started for another reason is not + // hijacked by a remembered connect request. + const explicitNext = url.searchParams.get("next"); + const pendingConnect = readPendingConnect(); const destination = - next.startsWith("/") && !next.startsWith("//") - ? next - : await resolveLandingPath(); + explicitNext && + explicitNext.startsWith("/") && + !explicitNext.startsWith("//") + ? explicitNext + : pendingConnect + ? `/link?request=${encodeURIComponent(pendingConnect)}` + : next.startsWith("/") && !next.startsWith("//") + ? next + : await resolveLandingPath(); console.log("[Auth Callback Debug] Redirecting to:", destination); setTimeout(() => navigate(destination, { replace: true }), 1500); diff --git a/frontend/editor/src/saas/routes/ConnectApprove.tsx b/frontend/editor/src/saas/routes/ConnectApprove.tsx new file mode 100644 index 0000000000..6374738713 --- /dev/null +++ b/frontend/editor/src/saas/routes/ConnectApprove.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; +import { useTranslation } from "@app/hooks/useTranslation"; +import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; +import { withBasePath } from "@app/constants/app"; +import { + clearPendingConnect, + rememberPendingConnect, +} from "@app/routes/pendingConnect"; +import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; +import AuthLayout from "@app/routes/authShared/AuthLayout"; +import { + ConnectApproveView, + type ApprovePhase, + type PendingConnect, +} from "@app/routes/ConnectApproveView"; +import "@app/routes/authShared/saas-auth.css"; +import "@app/routes/connect.css"; + +interface ApproveResponse { + callbackUrl: string; + nonce: string; +} + +/** Wider than the view renders: only PENDING is still actionable. */ +interface ConnectLookup extends PendingConnect { + status: "PENDING" | "APPROVED" | "DENIED" | "CONSUMED"; +} + +/** Approve a self-hosted server's request to connect to a team. */ +export default function ConnectApprove() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { session, user, loading, signOut } = useAuth(); + const [params] = useSearchParams(); + const requestId = params.get("request"); + + const [phase, setPhase] = useState("loading"); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const lookedUpRef = useRef(false); + + useDocumentMeta({ title: t("connect.meta.title", "Connect a server") }); + + // On arrival, not only when signed out: an approver who is already signed in can + // still be sent away to re-authenticate, and needs the same way back. + useEffect(() => { + if (requestId) rememberPendingConnect(requestId); + }, [requestId]); + + useEffect(() => { + if (loading || session) return; + // No basename: every consumer of `next` reaches it through navigate(), which + // applies the basename itself, so carrying it here yields /app/app/link. + const next = `/link${requestId ? `?request=${encodeURIComponent(requestId)}` : ""}`; + navigate(`/login?next=${encodeURIComponent(next)}`, { replace: true }); + }, [loading, session, requestId, navigate]); + + useEffect(() => { + if (loading || !session || lookedUpRef.current) return; + lookedUpRef.current = true; + if (!requestId) { + setPhase("notFound"); + return; + } + void (async () => { + try { + const res = await apiClient.get( + `/api/v1/account-link/connect/${encodeURIComponent(requestId)}`, + ); + // Approving a settled request fails server-side, so offering the form again + // would only produce a dead end. + if (res.data.status !== "PENDING") { + clearPendingConnect(); + setPhase(res.data.status === "DENIED" ? "declined" : "notFound"); + return; + } + setPending(res.data); + setPhase("confirm"); + } catch { + clearPendingConnect(); + setPhase("notFound"); + } + })(); + }, [loading, session, requestId]); + + const onDecide = useCallback( + async (approve: boolean) => { + if (!requestId) return; + setBusy(true); + setError(null); + const path = `/api/v1/account-link/connect/${encodeURIComponent(requestId)}`; + try { + if (!approve) { + await apiClient.post(`${path}/deny`); + clearPendingConnect(); + setPhase("declined"); + return; + } + const res = await apiClient.post(`${path}/approve`); + clearPendingConnect(); + setPhase("redirecting"); + window.location.replace(returnUrl(res.data, session)); + } catch { + setError( + t( + "connect.error.failed", + "That did not go through. Only a team owner can connect a server.", + ), + ); + } finally { + setBusy(false); + } + }, + [requestId, session, t], + ); + + /** + * Sign out, then let the signed-out effect above send them to login with the request preserved. + */ + const onSwitchAccount = useCallback(() => { + void signOut(); + }, [signOut]); + + if (loading || !session) return null; + + return ( + + {/* Same header as the sibling auth pages: an admin arriving from another + screen should be able to tell at a glance they are on our site and not + somewhere that merely looks like it. */} +
+ Stirling PDF + Stirling PDF +
+ + void onDecide(approve)} + onSwitchAccount={onSwitchAccount} + /> +
+ ); +} + +/** The callback with the session appended as a fragment. */ +function returnUrl( + approval: ApproveResponse, + session: { access_token?: string; refresh_token?: string } | null, +): string { + const fragment = new URLSearchParams({ type: "link", nonce: approval.nonce }); + if (session?.access_token && session?.refresh_token) { + fragment.set("access_token", session.access_token); + fragment.set("refresh_token", session.refresh_token); + } + return `${approval.callbackUrl}#${fragment.toString()}`; +} diff --git a/frontend/editor/src/saas/routes/ConnectApproveView.tsx b/frontend/editor/src/saas/routes/ConnectApproveView.tsx new file mode 100644 index 0000000000..26a2fe1150 --- /dev/null +++ b/frontend/editor/src/saas/routes/ConnectApproveView.tsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import { useTranslation } from "@app/hooks/useTranslation"; +import { Banner, Button, Checkbox, Spinner } from "@app/ui"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { Tooltip } from "@app/components/shared/Tooltip"; + +export type ApprovePhase = + | "loading" + | "confirm" + | "redirecting" + | "declined" + | "notFound"; + +/** What the approver is being asked to connect. */ +export interface PendingConnect { + requestId: string; + callbackOrigin: string; + insecureTransport: boolean; +} + +export interface ConnectApproveViewProps { + phase: ApprovePhase; + pending: PendingConnect | null; + /** Email of the account the server would be connected to. */ + signedInEmail: string | null; + busy: boolean; + error: string | null; + onDecide: (approve: boolean) => void; + /** Sign out and come back here, keeping the request so it survives the detour. */ + onSwitchAccount: () => void; +} + +/** Presentation for the connect approval page. */ +export function ConnectApproveView({ + phase, + pending, + signedInEmail, + busy, + error, + onDecide, + onSwitchAccount, +}: ConnectApproveViewProps) { + const { t } = useTranslation(); + // Gates the primary action: anyone can create a request, so the approver reading + // the address is the only thing between one and a linked team. + const [acknowledged, setAcknowledged] = useState(false); + + if (phase === "loading" || phase === "redirecting") { + return ( +
+ +

+ {phase === "redirecting" + ? t("connect.redirecting", "Returning you to your server.") + : t("connect.loading", "Checking this request.")} +

+
+ ); + } + + if (phase === "notFound") { + return ( +
+ + {t( + "connect.notFound.body", + "This connection request is not valid. It may have expired, or already been used. Start another one from your server.", + )} + +
+ ); + } + + if (phase === "declined") { + return ( +
+ + {t( + "connect.declined.body", + "Nothing was connected. You can close this page.", + )} + +
+ ); + } + + return ( +
+

+ {t("connect.confirm.title", "Connect this server?")} +

+

+ {t( + "connect.confirm.lead", + "A Stirling server is asking to connect to your team. Check the address below is yours before you approve.", + )} +

+ + {/* One panel, because the account and the address are two halves of the same + decision: right server, wrong account is still wrong. */} +
+
{t("connect.confirm.signedInAs", "Account")}
+
+ {signedInEmail ?? + t("connect.confirm.unknownAccount", "an unknown account")} + +
+ {/* The reported name is deliberately not shown. The requester chooses it on an + unauthenticated endpoint, so it is the field an attacker would set to look + familiar, and its honest value is the hostname already in the address. It + still labels the server in the linked-instances list, after the decision. */} +
+ {t("connect.confirm.originLabel", "Address")} + {pending?.insecureTransport ? ( + + + + + + ) : null} +
+
{pending?.callbackOrigin}
+
+ + {error ? {error} : null} + + setAcknowledged(e.currentTarget.checked)} + label={t( + "connect.confirm.acknowledge", + "I recognise this address and want to connect it to my team", + )} + /> + +
+ + +
+
+ ); +} diff --git a/frontend/editor/src/saas/routes/ResumePendingConnect.tsx b/frontend/editor/src/saas/routes/ResumePendingConnect.tsx new file mode 100644 index 0000000000..37950047bd --- /dev/null +++ b/frontend/editor/src/saas/routes/ResumePendingConnect.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/UseSession"; +import { readPendingConnect } from "@app/routes/pendingConnect"; + +/** + * Sends a newly signed-in visitor back to the approval page they were pulled away + * from. + * + * Mounted app-wide, not only in the auth callback: a confirmation email can land the + * visitor anywhere in the app with a session, and only the ones below resolve the + * request themselves. + */ +export function ResumePendingConnect() { + const { session, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const handled = useRef(false); + + useEffect(() => { + if (loading || !session || handled.current) return; + if ( + location.pathname === "/link" || + location.pathname === "/auth/callback" + ) { + return; + } + handled.current = true; + const requestId = readPendingConnect(); + if (requestId) { + navigate(`/link?request=${encodeURIComponent(requestId)}`, { + replace: true, + }); + } + }, [loading, session, location.pathname, navigate]); + + return null; +} diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css new file mode 100644 index 0000000000..5a709abfbe --- /dev/null +++ b/frontend/editor/src/saas/routes/connect.css @@ -0,0 +1,93 @@ +/* Connect-approval page. The origin is the thing the approver has to actually + read, so it gets the visual weight and everything else stays quiet. */ + +.saas-connect { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; +} + +.saas-connect__title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--c-text); +} + +.saas-connect__lead { + margin: 0; + font-size: 0.875rem; + color: var(--c-text-muted); +} + +/* Sits inside the facts panel rather than beside the email: at this width a + right-aligned action wraps onto its own line and reads as a third field. */ +.saas-connect__switch { + display: block; + margin-top: 0.125rem; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 0.8125rem; + color: var(--c-accent-text); + cursor: pointer; +} + +.saas-connect__switch:hover:not(:disabled) { + text-decoration: underline; +} + +.saas-connect__switch:disabled { + color: var(--c-text-muted); + cursor: default; +} + +.saas-connect__facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.375rem 1rem; + margin: 0; + padding: 0.875rem; + background: var(--c-surface-sunken); + border: 1px solid var(--c-border); + border-radius: 0.375rem; + font-size: 0.875rem; +} + +.saas-connect__facts dt { + margin: 0; + color: var(--c-text-muted); +} + +.saas-connect__facts dd { + margin: 0; + color: var(--c-text); + overflow-wrap: anywhere; +} + +/* Monospaced so a lookalike hostname is harder to skim past. */ +.saas-connect__origin { + font-family: var(--font-mono, ui-monospace, monospace); + font-weight: 600; +} + +.saas-connect__origin-label { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.saas-connect__insecure { + display: inline-flex; + flex: none; + color: var(--c-warning); + cursor: help; +} + +.saas-connect__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/saas/routes/pendingConnect.ts b/frontend/editor/src/saas/routes/pendingConnect.ts new file mode 100644 index 0000000000..c44af277be --- /dev/null +++ b/frontend/editor/src/saas/routes/pendingConnect.ts @@ -0,0 +1,61 @@ +/** + * Remembers that the visitor arrived wanting to connect a server, so a sign-in + * detour can return them to the approval page. + * + * localStorage, not sessionStorage: the confirmation email opens a new tab, and + * sessionStorage is per-tab — empty exactly when it is needed. + * + * Only the request id, which is already in the URL and carries no secret. This + * decides where the approver lands, never whether the link happens. + * + * Reading does not consume it: the request may be open in another tab, or the page + * closed and reopened, or the reader mounted twice. Only a recorded decision, or a + * request that is settled or gone, retires it. + */ +const KEY = "stirling-pending-connect"; + +/** Matches the server's request lifetime, so a stale intent cannot hijack a later sign-in. */ +const TTL_MS = 30 * 60 * 1000; + +interface Stored { + requestId: string; + at: number; +} + +export function rememberPendingConnect(requestId: string): void { + try { + const value: Stored = { requestId, at: Date.now() }; + window.localStorage.setItem(KEY, JSON.stringify(value)); + } catch { + // Private browsing or a full quota; nothing to fall back to. + } +} + +/** Drops the intent without reading it, once it has been acted on. */ +export function clearPendingConnect(): void { + try { + window.localStorage.removeItem(KEY); + } catch { + // Unwritable store; nothing to remove. + } +} + +/** The pending request, or null when absent or expired. Leaves it in place. */ +export function readPendingConnect(): string | null { + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return null; + const value = JSON.parse(raw) as Stored; + if (typeof value?.requestId !== "string" || typeof value?.at !== "number") { + clearPendingConnect(); + return null; + } + if (Date.now() - value.at > TTL_MS) { + clearPendingConnect(); + return null; + } + return value.requestId; + } catch { + return null; + } +} diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 96ce29c480..22b8ce4e24 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -259,6 +259,16 @@ export default defineConfig(async ({ mode, command }) => { }; return { + // Per-mode: the default is one shared node_modules/.vite, so two dev servers in + // different modes re-optimize over each other and the browser 504s on a stale dep + // hash. Anchored to frontend/ because a relative path resolves against the vite + // root (editor/) and would create a second node_modules there. + cacheDir: resolve( + import.meta.dirname, + "..", + "node_modules", + `.vite-${effectiveMode}`, + ), define: { __DEV_WORKTREE_LABEL__: JSON.stringify(devWorktreeLabel), }, From 897c72e9d900a8f5366a77f343e3bd4cdf427d72 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:55:31 +0000 Subject: [PATCH 05/17] Fix tool panel scrolling so the action button stays reachable (#7688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes After ui rework all scrolling in all tool panels stopped working This fixes this to allow tool panels to be scrollabe again ## What was wrong PDF/UA is the only convert target whose settings panel overflows the tool rail. Measured at 1920×1080: overflow was 0px for pdfa, pdfx, png, docx, epub, and 158px for pdfua. Its action button sat at bottom: 1220 in a 1080px viewport — 140px below the fold — and the info alert was clipped mid-sentence. The panel could be scrolled, but nothing said so (Mantine's scrollbar auto-hides). Normally the app would scroll the button into view for you. It didn't, because both mechanisms built to do that were dead ## Cause: Two separate mechanisms, both broken since the same commit (0a50e765b7, frontend editor restructure, 2026-05-22): 1. ReviewToolStep - shared by all 47 tools. It looked for its scroll container with: stepRef.current.closest('[style*="overflow: auto"]') Mantine's ScrollArea viewport sets inline overflow: scroll, not auto. I measured it live - closest() returns null, and document.querySelectorAll('[style*="overflow: auto"]') finds exactly 1 element anywhere in the page, and it isn't an ancestor of the panel. So the lookup silently found nothing and the scrollTo never ran, for every tool. 2. Convert.tsx - Convert only. It declared scrollContainerRef and a scrollToBottom() wired to two useEffects, but the ref was never attached to any element - createToolFlow() builds the JSX and no ref is passed through. Always null, so both effects were no-ops. Nothing else in the codebase has this pattern - I grepped for other closest('[style*="overflow…"]') lookups and other scrollToBottom/scrollContainerRef uses and both came back empty. ## The fix createToolFlow.module.css (new) + createToolFlow.tsx:156 — the execute button gets a position: sticky; bottom: 0 footer, the house pattern already used by FormFill.module.css. Applied only when the review step isn't visible, so it can never float over results. Sticky is inert when content fits, so the other 46 tools are untouched. ReviewToolStep.tsx:21 — real findScrollParent() walk replacing the broken selector, scrolling by the minimum delta needed and only the panel itself (never scrollIntoView(), which drags every ancestor). Also added the missing clearTimeout cleanup. Convert.tsx — deleted the dead ref and its two effects. --- ## 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/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 run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../tools/shared/ReviewToolStep.tsx | 66 ++++++++++++++----- .../tools/shared/createToolFlow.module.css | 22 +++++++ .../tools/shared/createToolFlow.tsx | 11 +++- frontend/editor/src/core/tools/Convert.tsx | 23 ------- 4 files changed, 79 insertions(+), 43 deletions(-) create mode 100644 frontend/editor/src/core/components/tools/shared/createToolFlow.module.css diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index ef596c2a0d..8f86faff17 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -14,6 +14,25 @@ import { saveOperationResults } from "@app/services/operationResultsSaveService" import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; import i18n from "@app/i18n"; +/** + * Nearest scrolling ancestor - in the right rail that is the tool panel's + * ScrollArea viewport, whose overflow is `scroll`, not `auto`. + */ +function findScrollParent(element: HTMLElement): HTMLElement | null { + let node = element.parentElement; + while (node) { + const { overflowY } = getComputedStyle(node); + if ( + /(auto|scroll|overlay)/.test(overflowY) && + node.scrollHeight > node.clientHeight + ) { + return node; + } + node = node.parentElement; + } + return null; +} + export interface ReviewToolStepProps { isVisible: boolean; operation: ToolOperationHook; @@ -81,26 +100,37 @@ function ReviewStepContent({ } }; - // Auto-scroll to bottom when content appears + // Reveal the results when they appear, or the download button lands below the + // fold behind a tall settings step and reads as missing. useEffect(() => { - if ( - stepRef.current && - (previewFiles.length > 0 || - operation.downloadUrl || - operation.errorMessage) - ) { - const scrollableContainer = stepRef.current.closest( - '[style*="overflow: auto"]', - ) as HTMLElement; - if (scrollableContainer) { - setTimeout(() => { - scrollableContainer.scrollTo({ - top: scrollableContainer.scrollHeight, - behavior: "smooth", - }); - }, 100); // Small delay to ensure content is rendered + const hasContent = + previewFiles.length > 0 || + operation.downloadUrl || + operation.errorMessage; + if (!stepRef.current || !hasContent) return; + + // Small delay so the step has been laid out before it is measured. + const timer = setTimeout(() => { + const step = stepRef.current; + const scroller = step && findScrollParent(step); + if (!step || !scroller) return; + + const stepRect = step.getBoundingClientRect(); + const viewRect = scroller.getBoundingClientRect(); + // Move the least that brings the step into view, and only ever the panel + // itself - scrollIntoView() drags every ancestor and unpins the header. + const delta = Math.min( + stepRect.top - viewRect.top, + stepRect.bottom - viewRect.bottom, + ); + if (delta > 1) { + scroller.scrollTo({ + top: scroller.scrollTop + delta, + behavior: "smooth", + }); } - } + }, 100); + return () => clearTimeout(timer); }, [previewFiles.length, operation.downloadUrl, operation.errorMessage]); return ( diff --git a/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css b/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css new file mode 100644 index 0000000000..bc7abb630d --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css @@ -0,0 +1,22 @@ +/* The tool panel scrolls as a single column, so a tall settings step (PDF/UA is + the worst offender) pushes the primary action below the fold with nothing to + say it is there. Pinning keeps it reachable; when the flow already fits, + sticky is inert and nothing moves. */ +.executeFooter { + position: sticky; + bottom: 0; + z-index: 2; + background: var(--c-surface, var(--mantine-color-body)); + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-sm); + /* Bleed across the flow's own padding so content cannot scroll through the + gutters beside the button. The margin cancels the padding, so an unpinned + footer still sits exactly where it did. */ + margin-inline: calc(var(--mantine-spacing-sm) * -1); + padding-inline: var(--mantine-spacing-sm); + /* Paint-only skirt covering the strip below the button once pinned; a padding + here would change the resting layout. */ + box-shadow: 0 var(--mantine-spacing-sm) 0 0 + var(--c-surface, var(--mantine-color-body)); +} diff --git a/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx b/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx index aafa9b3ea1..4b03de0a4b 100644 --- a/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx @@ -13,6 +13,7 @@ import { import { StirlingFile } from "@app/types/fileContext"; import type { TooltipTip } from "@app/types/tips"; import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes"; +import classes from "@app/components/tools/shared/createToolFlow.module.css"; export interface FilesStepConfig { selectedFiles: StirlingFile[]; @@ -152,8 +153,14 @@ export function createToolFlow( : eb.paramsValid === false ? "invalidParams" : null; + // Pin the action only while it is the last thing in the flow; with a + // review below it, a sticky footer would float over the results. return ( - <> +
( data-tour="run-button" /> {config.belowExecuteButton} - +
); })()} diff --git a/frontend/editor/src/core/tools/Convert.tsx b/frontend/editor/src/core/tools/Convert.tsx index eb9a4a02c5..733a21b6e4 100644 --- a/frontend/editor/src/core/tools/Convert.tsx +++ b/frontend/editor/src/core/tools/Convert.tsx @@ -36,7 +36,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }); setSelectedFiles(matching.map((file) => file.fileId)); }; - const scrollContainerRef = useRef(null); const convertParams = useConvertParameters(); const convertOperation = useConvertOperation(convertParams.parameters); @@ -48,16 +47,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const skipNextSelectionResetRef = useRef(false); const previousSelectionRef = useRef(""); - const scrollToBottom = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: scrollContainerRef.current.scrollHeight, - behavior: "smooth", - }); - } - }; - - const hasFiles = selectedFiles.length > 0; const hasResults = convertOperation.files.length > 0 || convertOperation.downloadUrl !== null || @@ -115,18 +104,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { convertParams.parameters.toExtension, ]); - useEffect(() => { - if (hasFiles) { - setTimeout(scrollToBottom, 100); - } - }, [hasFiles]); - - useEffect(() => { - if (hasResults) { - setTimeout(scrollToBottom, 100); - } - }, [hasResults]); - const handleConvert = async () => { try { await convertOperation.executeOperation( From a215c30068503c825729ed7aa8a9ba9ebb384314 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:10:56 +0000 Subject: [PATCH 06/17] Add the missing en-US translations for classification labels (#7692) Our classification labels were rendering their hardcoded English names because the en-US locale file had no `classification` section at all, so this adds the missing keys (labels and category names). Also wires the category names through i18n, since those had no `t()` call, and adds a test so a new label can't ship without its key. --- .../public/locales/en-US/translation.toml | 287 ++++++++++++++++++ .../policies/ClassificationLabelsSection.tsx | 2 +- .../ClassificationCategoryManager.tsx | 4 +- .../shared/fileSidebarGroupingLogic.ts | 2 +- .../data/classificationLabels.i18n.test.ts | 58 ++++ 5 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4af1be3359..8271d66dfc 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3001,6 +3001,293 @@ summary_one = "Ran 1 tool" summary_other = "Ran {{count}} tools" unknownTool = "Unknown tool" +[classification.families] +correspondence = "Correspondence" +education = "Education" +engineering = "Engineering" +finance = "Financial" +forms = "Forms" +government = "Government" +health = "Medical" +hr = "HR" +legal = "Legal" +operations = "Operations" +projects = "Projects" +property = "Property" +reports = "Reports" +sales = "Marketing" +travel = "Travel" + +[classification.labels] +academic-record = "Academic record" +action-plan = "Action plan" +addendum = "Addendum" +advertisement = "Advertisement" +affidavit = "Affidavit" +agenda = "Agenda" +amendment = "Amendment" +analytics-report = "Analytics report" +announcement = "Announcement" +annual-report = "Annual report" +api-documentation = "API documentation" +application-form = "Application form" +appraisal-report = "Appraisal report" +architecture-document = "Architecture document" +articles-of-incorporation = "Articles of incorporation" +assignment-brief = "Assignment brief" +audit-report = "Audit report" +balance-sheet = "Balance sheet" +bank-statement = "Bank statement" +benefits-summary = "Benefits summary" +bill-of-lading = "Bill of lading" +bill-of-materials = "Bill of materials" +blueprint = "Blueprint" +board-report = "Board report" +board-resolution = "Board resolution" +booking-confirmation = "Booking confirmation" +brochure = "Brochure" +budget = "Budget" +business-plan = "Business plan" +business-proposal = "Business proposal" +bylaws = "Bylaws" +campaign-brief = "Campaign brief" +case-study = "Case study" +cash-flow-statement = "Cash flow statement" +catalog = "Catalog" +cease-and-desist = "Cease and desist" +certificate = "Certificate" +certificate-of-completion = "Certificate of completion" +change-log = "Change log" +checklist = "Checklist" +claim-form = "Claim form" +closing-statement = "Closing statement" +complaint-letter = "Complaint letter" +compliance-document = "Compliance document" +confirmation-letter = "Confirmation letter" +consent-form = "Consent form" +contract = "Contract" +course-syllabus = "Course syllabus" +court-filing = "Court filing" +cover-letter = "Cover letter" +credit-note = "Credit note" +customs-declaration = "Customs declaration" +customs-form = "Customs form" +cv = "CV" +datasheet = "Datasheet" +debit-note = "Debit note" +deed = "Deed" +delivery-note = "Delivery note" +demand-letter = "Demand letter" +design-document = "Design document" +diploma = "Diploma" +discharge-summary = "Discharge summary" +dissertation = "Dissertation" +donation-receipt = "Donation receipt" +dunning-letter = "Dunning letter" +email-thread = "Email thread" +employee-handbook = "Employee handbook" +employment-contract = "Employment contract" +estimate = "Estimate" +event-agenda = "Event agenda" +event-program = "Event program" +eviction-notice = "Eviction notice" +exam-paper = "Exam paper" +expense-report = "Expense report" +expense-summary = "Expense summary" +explanation-of-benefits = "Explanation of benefits" +fact-sheet = "Fact sheet" +faq-document = "FAQ document" +feasibility-study = "Feasibility study" +feedback-form = "Feedback form" +financial-forecast = "Financial forecast" +financial-statement = "Financial statement" +floor-plan = "Floor plan" +flyer = "Flyer" +form = "Form" +franchise-agreement = "Franchise agreement" +freight-document = "Freight document" +gift-certificate = "Gift certificate" +glossary = "Glossary" +government-notice = "Government notice" +grade-report = "Grade report" +grant-agreement = "Grant agreement" +grant-application = "Grant application" +hoa-document = "HOA document" +home-inspection-report = "Home inspection report" +hr-memo = "HR memo" +hr-policy = "HR policy" +immigration-document = "Immigration document" +immunization-record = "Immunization record" +incident-report = "Incident report" +income-statement = "Income statement" +index = "Index" +inspection-report = "Inspection report" +insurance-certificate = "Insurance certificate" +insurance-claim = "Insurance claim" +insurance-policy = "Insurance policy" +intake-form = "Intake form" +inventory-list = "Inventory list" +investment-summary = "Investment summary" +invitation = "Invitation" +invoice = "Invoice" +itinerary = "Itinerary" +job-application = "Job application" +job-description = "Job description" +lab-report = "Lab report" +lease-agreement = "Lease agreement" +leave-request = "Leave request" +legal-brief = "Legal brief" +legal-filing = "Legal filing" +legal-notice = "Legal notice" +legal-opinion = "Legal opinion" +lesson-plan = "Lesson plan" +letter = "Letter" +letter-of-intent = "Letter of intent" +license = "License" +license-agreement = "License agreement" +loan-agreement = "Loan agreement" +loan-document = "Loan document" +maintenance-log = "Maintenance log" +manual = "Manual" +market-research = "Market research" +marketing-plan = "Marketing plan" +media-kit = "Media kit" +medical-invoice = "Medical invoice" +medical-report = "Medical report" +meeting-agenda = "Meeting agenda" +meeting-minutes = "Meeting minutes" +meeting-notes = "Meeting notes" +membership-document = "Membership document" +memo = "Memo" +memorandum-of-understanding = "Memorandum of understanding" +mortgage-document = "Mortgage document" +nda = "NDA" +newsletter = "Newsletter" +non-compete-agreement = "Non-compete agreement" +notice = "Notice" +offer-letter = "Offer letter" +onboarding-document = "Onboarding document" +order-confirmation = "Order confirmation" +order-form = "Order form" +organization-chart = "Organization chart" +packing-slip = "Packing slip" +partnership-agreement = "Partnership agreement" +patent = "Patent" +pathology-report = "Pathology report" +payment-reminder = "Payment reminder" +payroll-document = "Payroll document" +payslip = "Payslip" +performance-review = "Performance review" +permit = "Permit" +petition = "Petition" +pitch-deck = "Pitch deck" +power-of-attorney = "Power of attorney" +prescription = "Prescription" +presentation = "Presentation" +press-release = "Press release" +price-list = "Price list" +pricing-sheet = "Pricing sheet" +privacy-policy = "Privacy policy" +product-sheet = "Product sheet" +proforma-invoice = "Proforma invoice" +progress-report = "Progress report" +project-charter = "Project charter" +project-plan = "Project plan" +promotional-material = "Promotional material" +property-listing = "Property listing" +proposal = "Proposal" +public-notice = "Public notice" +purchase-agreement = "Purchase agreement" +purchase-order = "Purchase order" +quality-report = "Quality report" +quarterly-report = "Quarterly report" +questionnaire = "Questionnaire" +quick-start-guide = "Quick start guide" +quote = "Quote" +radiology-report = "Radiology report" +receipt = "Receipt" +recommendation-letter = "Recommendation letter" +reference-letter = "Reference letter" +referral-letter = "Referral letter" +registration-confirmation = "Registration confirmation" +registration-form = "Registration form" +regulatory-filing = "Regulatory filing" +release-notes = "Release notes" +remittance-advice = "Remittance advice" +rental-agreement = "Rental agreement" +report = "Report" +request-for-proposal = "Request for proposal" +request-for-quotation = "Request for quotation" +requirements-document = "Requirements document" +research-abstract = "Research abstract" +research-paper = "Research paper" +reservation = "Reservation" +resignation-letter = "Resignation letter" +resume = "Resume" +retrospective = "Retrospective" +return-authorization = "Return authorization" +risk-assessment = "Risk assessment" +roadmap = "Roadmap" +safety-data-sheet = "Safety data sheet" +safety-procedure = "Safety procedure" +sales-proposal = "Sales proposal" +sales-report = "Sales report" +schematic = "Schematic" +scope-of-work = "Scope of work" +service-agreement = "Service agreement" +service-report = "Service report" +settlement-agreement = "Settlement agreement" +shareholder-agreement = "Shareholder agreement" +shipping-confirmation = "Shipping confirmation" +specification = "Specification" +sponsorship-agreement = "Sponsorship agreement" +standard-operating-procedure = "Standard operating procedure" +statement-of-account = "Statement of account" +statement-of-work = "Statement of work" +status-report = "Status report" +stock-report = "Stock report" +study-guide = "Study guide" +subpoena = "Subpoena" +subscription-confirmation = "Subscription confirmation" +supply-order = "Supply order" +survey-form = "Survey form" +survey-results = "Survey results" +sustainability-report = "Sustainability report" +table-of-contents = "Table of contents" +tax-form = "Tax form" +tax-return = "Tax return" +tax-statement = "Tax statement" +technical-drawing = "Technical drawing" +technical-specification = "Technical specification" +tenancy-agreement = "Tenancy agreement" +tender-document = "Tender document" +termination-letter = "Termination letter" +terms-and-conditions = "Terms and conditions" +terms-of-service = "Terms of service" +test-plan = "Test plan" +test-report = "Test report" +thesis = "Thesis" +ticket = "Ticket" +timeline = "Timeline" +timesheet = "Timesheet" +title-document = "Title document" +training-material = "Training material" +transcript = "Transcript" +travel-itinerary = "Travel itinerary" +trust-document = "Trust document" +user-guide = "User guide" +utility-bill = "Utility bill" +vendor-agreement = "Vendor agreement" +visa-document = "Visa document" +waiver = "Waiver" +warehouse-receipt = "Warehouse receipt" +warranty-document = "Warranty document" +waybill = "Waybill" +white-paper = "White paper" +will = "Will" +work-instruction = "Work instruction" +work-order = "Work order" + [cloudBadge] tooltip = "This operation will use your cloud credits" diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx index 7a21cae2b7..4b3fae7652 100644 --- a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx @@ -61,7 +61,7 @@ export function ClassificationLabelsSection() { )} - {family.name} + {t(`classification.families.${family.id}`, family.name)} } diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx index 143e344f82..bc29f74bae 100644 --- a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx @@ -40,7 +40,9 @@ export function ClassificationCategoryManager({ - {category.name} + + {t(`classification.families.${category.id}`, category.name)} + {count !== undefined && ( {count} )} diff --git a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts index 5b4b30ce33..d8b06f12f4 100644 --- a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts +++ b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts @@ -56,7 +56,7 @@ export function buildLabelGroups( if (members.length === 0) continue; visible.push({ id: `category:${category.id}`, - label: category.name, + label: t(`classification.families.${category.id}`, category.name), icon: category.icon, stubs: members, defaultExpanded: false, diff --git a/frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts b/frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts new file mode 100644 index 0000000000..842a7127e7 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts @@ -0,0 +1,58 @@ +import fs from "fs"; +import path from "path"; +import { describe, it, expect } from "vitest"; +import { parse } from "smol-toml"; +import { + DEFAULT_CLASSIFICATION_LABELS, + LABEL_FAMILIES, +} from "@app/data/classificationLabels"; + +// Label and family names are rendered through `classification.labels.` / +// `classification.families.`, with the built-in English name as the fallback. +// A fallback renders fine but is never translated (and logs a missingKey), so a +// label added to classificationLabels.json without its en-US key silently ships +// English to every locale. This guards that the en-US vocabulary stays complete. + +const EN_US_TRANSLATIONS = path.join( + __dirname, + "../../../public/locales/en-US/translation.toml", +); + +function readSection(section: string): Record { + const parsed = parse(fs.readFileSync(EN_US_TRANSLATIONS, "utf8")) as { + classification?: Record>; + }; + return parsed.classification?.[section] ?? {}; +} + +describe("classification vocabulary (en-US coverage)", () => { + const labels = readSection("labels"); + const families = readSection("families"); + + it("has an en-US key for every label id, matching its built-in name", () => { + for (const label of DEFAULT_CLASSIFICATION_LABELS) { + expect( + labels[label.id], + `en-US missing classification.labels.${label.id}`, + ).toBe(label.name); + } + }); + + it("has an en-US key for every family id, matching its built-in name", () => { + for (const family of LABEL_FAMILIES) { + expect( + families[family.id], + `en-US missing classification.families.${family.id}`, + ).toBe(family.name); + } + }); + + it("has no en-US keys for labels or families that no longer exist", () => { + const labelIds = new Set(DEFAULT_CLASSIFICATION_LABELS.map((l) => l.id)); + const familyIds = new Set(LABEL_FAMILIES.map((f) => f.id)); + expect(Object.keys(labels).filter((id) => !labelIds.has(id))).toEqual([]); + expect(Object.keys(families).filter((id) => !familyIds.has(id))).toEqual( + [], + ); + }); +}); From 5b5e9220697a3d80cadde04df5e4ec4e5fac79cf Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:57:15 +0000 Subject: [PATCH 07/17] Make the upgrade banner neutral instead of gradient purple (#7696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The `promo` banner tone was a full-bleed `indigo-500 → purple-500` gradient with white text and a black drop-shadow on the CTA. It was the only saturated fill in the app, and against the warm neutral palette it read as a foreign object above the workbench. The bar is now app chrome: | | Before | After | |---|---|---| | Background | 135° indigo→purple gradient | `--c-bg-raised` | | Border | `transparent` | `--c-border-subtle` hairline | | Icon | white glyph, no container | neutral glyph in a `--c-surface-sunken` chip | | Text | forced white | `--c-text` / `--c-text-muted` | | CTA | `premium` accent (violet gradient) | `default` accent (same primary button as the rest of the app) | Before Screenshot 2026-08-27 at 4 49 00 PM After Screenshot 2026-08-27 at 4 48 30 PM Only caller is the friendly variant of `UpgradeBanner` (self-hosted, under the free-tier user limit). ## Notes - **No new theme tokens.** Every value is an existing `--c-*` semantic token, so light and dark both follow automatically with no per-theme overrides. - The `premium` accent itself is untouched, so the upgrade CTAs in `OfflineActivationCard` and `PairingPanel` are unaffected. - `--c-hue-indigo` / `--c-hue-purple` are still used by `SaaSOnboardingSlides`, `PaygFree` and `UpgradeModal`, so no tokens are orphaned. - Deleted comments describe rules that no longer exist (the gradient, the white-on-gradient text overrides, the CTA shadow). No new comments added. ## Verification - `task frontend:check:all` passes (typecheck, oxlint, all four theme linters, stylelint, format, tests, build, storybook build). - `task frontend:storybook:a11y:changed` passes light and dark: 7 AppBanner stories, 0 violations. Both a11y baselines are empty, so this is zero known violations rather than a baselined pass. - Checked in Storybook under **Shared / AppBanner → All Top Bars**, which renders every top bar the app can show side by side, in both themes. --- .../src/core/components/shared/AppBanner.css | 31 ++++++++----------- .../src/core/components/shared/AppBanner.tsx | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/editor/src/core/components/shared/AppBanner.css b/frontend/editor/src/core/components/shared/AppBanner.css index b3f3f9343a..8151fe916a 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.css +++ b/frontend/editor/src/core/components/shared/AppBanner.css @@ -24,17 +24,10 @@ --app-banner-icon: var(--c-accent-fg, var(--c-primary)); } -/* The one bar meant to pop, so it takes the feature gradient rather than a tint. - Fixed hues by design — it doesn't follow the chosen accent. */ .app-banner--promo { - --app-banner-bg: linear-gradient( - 135deg, - var(--c-hue-indigo) 0%, - var(--c-hue-purple) 100% - ); - --app-banner-border: transparent; - --app-banner-icon: var(--color-text-on-accent); - color: var(--color-text-on-accent); + --app-banner-bg: var(--c-bg-raised); + --app-banner-border: var(--c-border-subtle); + --app-banner-icon: var(--c-text-muted); } .app-banner--warning { @@ -86,16 +79,18 @@ font-size: 0.75rem; } -/* On the gradient everything is white; muted grey would disappear. */ -.app-banner--promo .app-banner__message, -.app-banner--promo .app-banner__actions .sui-btn--tertiary, -.app-banner--promo .app-banner__actions .sui-ai { - color: var(--color-text-on-accent); +.app-banner--promo .app-banner__icon { + width: 1.75rem; + height: 1.75rem; + justify-content: center; + border-radius: var(--radius-md); + background: var(--c-surface-sunken); + box-shadow: inset 0 0 0 1px var(--c-border-subtle); } -/* Lifts the premium CTA off the gradient it sits on. */ -.app-banner--promo .app-banner__actions .sui-btn--primary { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +.app-banner--promo.app-banner--compact .app-banner__icon { + width: 1.5rem; + height: 1.5rem; } .app-banner__actions { diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx index ee0ba03741..79ff268dd2 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.tsx +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -11,7 +11,7 @@ export type AppBannerTone = "info" | "promo" | "warning" | "danger"; /** Tone decides the button too, so the CTA can't drift from the bar it sits on. */ const TONE_BUTTON = { info: { variant: "secondary", accent: "default" }, - promo: { variant: "primary", accent: "premium" }, + promo: { variant: "primary", accent: "default" }, warning: { variant: "primary", accent: "warning" }, danger: { variant: "primary", accent: "danger" }, } as const; From cbe3ef8f69240eb4ddce063b3f85c9fafe7a9bda Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 16:59:58 +0000 Subject: [PATCH 08/17] fix: validate frontend dependency installation (#7625) # Description of Changes The current check only verifies the existence of the `node_modules` directory. After an incomplete or corrupted installation, this can lead to the task being incorrectly marked as complete. `npm ls --depth=0` instead checks whether the direct frontend dependencies are actually installed and consistent. This reliably detects and automatically repairs corrupted installations. --- ## 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/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 run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/labeler-config-srvaroa.yml | 1 + .taskfiles/frontend.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index bd1947d649..ea9613ea20 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -67,6 +67,7 @@ labels: - 'frontend/**' - 'frontend/.*' - 'frontend/**/.*' + - '.taskfiles/frontend.yml' - label: 'Tauri' files: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 7867315037..9727538961 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -23,7 +23,7 @@ tasks: - package-lock.json - package.json status: - - test -d node_modules + - npm ls --depth=0 env: CI: '{{ .CI | default "false" }}' From 4e46ba3b5a98cd4dd73ef4b898ba78d7536a0678 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 17:16:24 +0000 Subject: [PATCH 09/17] chore: prevent duplicate Dependabot Gradle PRs (#7657) ## Description of Changes - Removed overlapping Gradle subdirectory entries from .github/dependabot.yml. - Dependabot now monitors the root Gradle project through /. - Prevents duplicate pull requests for dependencies declared in Gradle subprojects. Closes: Not applicable --- ## Checklist ### General - [ ] I have read the Contribution Guidelines - [ ] I have read the Stirling-PDF Developer Guide (if applicable) - [x] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant documentation (if applicable) - [ ] I have read the translation tag documentation (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos are attached ### Testing (if applicable) - [ ] I have tested my changes locally --- .github/dependabot.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 883c4f7f46..4ed61e37ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,11 +8,6 @@ updates: - package-ecosystem: "gradle" # See documentation for possible values directories: - "/" # Location of package manifests - - "/app/common" - - "/app/core" - - "/app/proprietary" - - "/app/saas" - - "/buildSrc" schedule: interval: "weekly" cooldown: From 97c0ccf58214fe502c0f93ac859d264dc58c3c00 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:31:54 +0200 Subject: [PATCH 10/17] refactor(deps): optimize dependency footprints, and add lazy initialization with platform-specific JPDFium bundling (#7620) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/common/build.gradle | 39 +++++++++++++++---- app/core/build.gradle | 16 +++++++- .../api/security/RedactController.java | 4 ++ .../api/security/TextRedactionService.java | 4 ++ build.gradle | 10 ++++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 8af68bcb76..8ac1dfe0a9 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -3,6 +3,10 @@ bootRun { enabled = false } dependencies { + // Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection). + // Declared as api here so core + proprietary (which depend on common) get it transitively, + // keeping it off modules that don't need it (e.g. saas). + api 'io.github.pixee:java-security-toolkit:1.2.3' api "com.google.guava:guava:${guavaVersion}" api 'org.springframework.boot:spring-boot-starter-webmvc' api 'org.springframework.boot:spring-boot-starter-aspectj' @@ -22,7 +26,10 @@ dependencies { api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) api 'org.simplejavamail:simple-java-mail:9.3.2' - api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support + // MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't) + api('org.simplejavamail:outlook-module:9.3.2') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' @@ -36,12 +43,30 @@ dependencies { api "com.stirling:jpdfium:${jpdfiumVersion}" - // -PjpdfiumPlatforms=all|none| - // 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform). - def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() - def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] + // -PjpdfiumPlatforms=auto|all|none| (windows-arm64 natives not published yet) + def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim() + def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] def jpdfiumPlatforms - if (jpdfiumPlatformsProp == 'all') { + if (jpdfiumPlatformsProp == 'auto') { + def osName = System.getProperty('os.name').toLowerCase() + def osArch = System.getProperty('os.arch').toLowerCase() + def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64') + if (osName.contains('linux')) { + jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64'] + } else if (osName.contains('mac')) { + jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64'] + } else if (osName.contains('win')) { + if (isArm64) { + logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.") + jpdfiumPlatforms = [] + } else { + jpdfiumPlatforms = ['windows-x64'] + } + } else { + // Fallback: bundle all platforms when host can't be determined + jpdfiumPlatforms = jpdfiumAllPlatforms + } + } else if (jpdfiumPlatformsProp == 'all') { jpdfiumPlatforms = jpdfiumAllPlatforms } else if (jpdfiumPlatformsProp == 'none') { jpdfiumPlatforms = [] @@ -51,7 +76,7 @@ dependencies { def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) } if (jpdfiumInvalid) { throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " + - "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.") + "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.") } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}") jpdfiumPlatforms.each { platform -> diff --git a/app/core/build.gradle b/app/core/build.gradle index 76828445a3..0e1533b6e8 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -62,8 +62,16 @@ dependencies { // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5' - implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv - implementation 'org.apache.poi:poi-ooxml:5.5.1' + // OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module. + // Exclude commons-beanutils + commons-collections. + implementation('com.opencsv:opencsv:5.12.0') { + exclude group: 'commons-beanutils', module: 'commons-beanutils' + exclude group: 'commons-collections', module: 'commons-collections' + } + // POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3. + implementation('org.apache.poi:poi-ooxml:5.5.1') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom) // Replaces batik-all which included unused codec, svggen, transcoder, script modules @@ -129,6 +137,10 @@ bootJar { exclude 'META-INF/*.RSA' exclude 'META-INF/*.EC' + // Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime + exclude 'static/pdfjs-legacy/**/*.map' + exclude 'static/**/*.map' + manifest { attributes( 'Implementation-Title': 'Stirling-PDF', diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 7a186235cd..1c694da2b0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -221,6 +221,10 @@ public class RedactController { .normalizeFonts(false) .fixToUnicode(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) // WIP/Experimental API .redactMetadata(true) .build(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java index 9cf4e6c700..c0b74f5428 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java @@ -110,6 +110,10 @@ class TextRedactionService { .fixToUnicode(false) .repairWidths(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) .build(); try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) { diff --git a/build.gradle b/build.gradle index 6382e75c12..ffaba65377 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ ext { bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" - jpdfiumVersion = "1.0.4" + jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" awsSdkVersion = "2.51.3" jschVersion = "2.28.6" @@ -265,7 +265,6 @@ subprojects { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.github.pixee:java-security-toolkit:1.2.3' //tmp for security bumps implementation "ch.qos.logback:logback-core:$logback" @@ -543,6 +542,13 @@ subprojects { } } + // Lazy initialization defers bean creation until first use, + // reducing dev-mode RSS significantly (heap drops ~40-60%). + // Enable with: ./gradlew bootRun -PlazyInit=true + if (rootProject.findProperty('lazyInit') == 'true') { + runtimeArgs.add("-Dspring.main.lazy-initialization=true") + logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)") + } jvmArgs = runtimeArgs } } From 51835a7b5e8ac2f058aa7cee4e29906c94823afc Mon Sep 17 00:00:00 2001 From: jayakrishna <71440165+NGU-152002@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:04:43 +0530 Subject: [PATCH 11/17] fix: clean up Add Stamp image preview blob URLs (#6779) Co-authored-by: James Brunton --- .../core/components/tools/addStamp/StampSetupSettings.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx index 4c7ee96c5b..25e2bc2243 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx @@ -19,6 +19,7 @@ import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampPa import ButtonSelector from "@app/components/shared/ButtonSelector"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils"; +import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; const STAMP_TEMPLATES = [ @@ -209,6 +210,9 @@ const StampSetupSettings = ({ filename, }: StampSetupSettingsProps) => { const { t } = useTranslation(); + const stampImageWithUrl = useFileWithUrl( + parameters.stampType === "image" ? (parameters.stampImage ?? null) : null, + ); return ( @@ -679,10 +683,10 @@ const StampSetupSettings = ({ > {t("chooseFile", "Choose File")} - {parameters.stampImage && ( + {parameters.stampImage && stampImageWithUrl && ( Selected stamp image From d4b1862654b4ad92414dc0a0ce25f8370bb459b5 Mon Sep 17 00:00:00 2001 From: Andrei Blaj <7049755+andreiblaj@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:40:26 +0300 Subject: [PATCH 12/17] feat(ocr): add rotatePages option for automatic page orientation correction (#6697) Co-authored-by: Andrei Blaj Signed-off-by: Andrei Blaj --- .../software/SPDF/controller/api/misc/OCRController.java | 7 +++++++ .../SPDF/model/api/misc/ProcessPdfWithOcrRequest.java | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 4803184a33..e10b86a866 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -114,6 +114,7 @@ public class OCRController { List selectedLanguages = request.getLanguages(); boolean sidecar = request.isSidecar(); Boolean deskew = request.isDeskew(); + Boolean rotatePages = request.isRotatePages(); Boolean clean = request.isClean(); Boolean cleanFinal = request.isCleanFinal(); String ocrType = request.getOcrType(); @@ -154,6 +155,7 @@ public class OCRController { selectedLanguages, sidecar, deskew, + rotatePages, clean, cleanFinal, ocrType, @@ -236,6 +238,7 @@ public class OCRController { List selectedLanguages, Boolean sidecar, Boolean deskew, + Boolean rotatePages, Boolean clean, Boolean cleanFinal, String ocrType, @@ -268,6 +271,10 @@ public class OCRController { if (deskew != null && deskew) { command.add("--deskew"); } + if (rotatePages != null && rotatePages) { + // Tesseract OSD-based automatic page orientation correction (90/180/270) + command.add("--rotate-pages"); + } if (clean != null && clean) { command.add("--clean"); } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java index 2955d7160f..daa6930412 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java @@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile { @Schema(description = "Deskew the input file if set to true") private boolean deskew; + @Schema( + description = + "Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true") + private boolean rotatePages; + @Schema(description = "Clean the input file if set to true") private boolean clean; From be130282098c01d15591fc20af3532c136c11172 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 19:41:40 +0200 Subject: [PATCH 13/17] chore(logging): enable gzipped log rotation and adjust test logging (#7648) --- app/core/.gitignore | 1 + app/core/src/main/resources/logback.xml | 10 ++++++---- build.gradle | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/core/.gitignore b/app/core/.gitignore index 7d9dd62931..c207c6c09a 100644 --- a/app/core/.gitignore +++ b/app/core/.gitignore @@ -106,6 +106,7 @@ SwaggerDoc.json # Log file *.log +*.log.gz # BlueJ files *.ctxt diff --git a/app/core/src/main/resources/logback.xml b/app/core/src/main/resources/logback.xml index c0779735ae..ebdeeda64b 100644 --- a/app/core/src/main/resources/logback.xml +++ b/app/core/src/main/resources/logback.xml @@ -16,8 +16,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz + 7 + 64MB @@ -28,8 +29,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/info-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz + 7 + 256MB diff --git a/build.gradle b/build.gradle index ffaba65377..fdc59f3680 100644 --- a/build.gradle +++ b/build.gradle @@ -306,7 +306,7 @@ subprojects { systemProperty 'apple.awt.UIElement', 'true' testLogging { - events "started", "failed" + events "skipped", "failed" showExceptions = true showCauses = true showStackTraces = true From f71b0247dafba7d266dcc3d0f8e4dcffdfbbbda3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:15:39 +0100 Subject: [PATCH 14/17] Quick access bar and old school sidebars (#7695) --- .../public/locales/en-US/translation.toml | 19 +- frontend/editor/src/core/App.tsx | 28 +-- .../fileManager/FileSourceButtons.tsx | 2 +- .../components/filesPage/FolderTreePanel.tsx | 2 +- .../filesPage/filesPageReturnRoute.ts | 2 +- .../src/core/components/layout/AppFrame.css | 18 ++ .../src/core/components/layout/AppFrame.tsx | 22 ++ .../core/components/layout/NoAppChrome.tsx | 8 + .../components/layout/Workbench.module.css | 6 +- .../src/core/components/layout/Workbench.tsx | 30 ++- .../core/components/layout/WorkspaceFrame.css | 16 ++ .../notifications/NotificationBell.css | 8 +- .../notifications/NotificationBell.tsx | 119 ++--------- .../notifications/NotificationPanel.tsx | 135 ++++++++++++ .../src/core/components/shared/AppSwitch.tsx | 7 +- .../core/components/shared/AppSwitcher.tsx | 22 -- .../core/components/shared/BrandSwitcher.css | 15 -- .../shared/BrandSwitcher.stories.tsx | 16 -- .../core/components/shared/BrandSwitcher.tsx | 57 ----- .../src/core/components/shared/BrandTile.tsx | 29 +++ .../core/components/shared/FileSidebar.css | 25 ++- .../core/components/shared/FileSidebar.tsx | 60 +++--- .../core/components/shared/SidebarHeader.tsx | 33 +++ .../components/shared/SidebarToggleButton.tsx | 36 ++++ .../src/core/components/shared/Tooltip.tsx | 47 ++-- .../core/components/shared/WorkbenchBar.css | 22 +- .../core/components/shared/WorkbenchBar.tsx | 20 +- .../components/shared/navFooter/NavFooter.tsx | 87 ++++---- .../shared/quickNav/QuickNavBrand.tsx | 28 +++ .../shared/quickNav/QuickNavHostBridge.tsx | 85 ++++++++ .../shared/quickNav/QuickNavRail.css | 173 +++++++++++++++ .../shared/quickNav/QuickNavRailAccount.css | 29 +++ .../shared/quickNav/QuickNavRailAccount.tsx | 45 ++++ .../shared/quickNav/QuickNavRailBase.test.tsx | 123 +++++++++++ .../shared/quickNav/QuickNavRailBase.tsx | 101 +++++++++ .../shared/quickNav/QuickNavRailContainer.css | 38 ++++ .../shared/quickNav/QuickNavRailContainer.tsx | 83 ++++++++ .../shared/quickNav/QuickNavRailHost.tsx | 178 ++++++++++++++++ .../QuickNavRailNotifications.test.tsx | 88 ++++++++ .../quickNav/QuickNavRailNotifications.tsx | 51 +++++ .../quickNav/useQuickNavToolReasons.test.tsx | 139 ++++++++++++ .../shared/quickNav/useQuickNavToolReasons.ts | 131 ++++++++++++ .../core/components/tools/RightSidebar.tsx | 7 +- .../src/core/components/tools/ToolPanel.css | 8 +- .../viewer/useViewerWorkbenchBarButtons.tsx | 55 ++--- .../contexts/QuickNavHostContext.test.tsx | 128 +++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 201 ++++++++++++++++++ .../src/core/contexts/SidebarContext.tsx | 5 + .../src/core/contexts/ToolWorkflowContext.tsx | 2 +- frontend/editor/src/core/pages/HomePage.tsx | 191 ++++++++++++++--- frontend/editor/src/core/routes/hasPortal.ts | 2 + .../live/viewer-sidebar-add-buttons.spec.ts | 3 +- .../viewer-sidebar-add-buttons.spec.ts | 3 +- .../stubbed/workbench-session-restore.spec.ts | 10 +- frontend/editor/src/core/theme/colors.css | 2 +- frontend/editor/src/core/theme/dimensions.css | 7 +- frontend/editor/src/core/ui/NavSurface.tsx | 4 +- .../src/core/utils/homePageNavigation.ts | 9 +- .../src/core/utils/pendingReaderMode.ts | 13 ++ .../src/core/utils/viewTransition.test.ts | 63 ++++++ .../editor/src/core/utils/viewTransition.ts | 17 +- .../desktop/components/shared/AppSwitcher.tsx | 18 -- .../editor/src/desktop/routes/hasPortal.ts | 2 + .../editor/src/portal/components/AppShell.tsx | 22 +- .../portal/components/EditorStatusCard.tsx | 26 +-- .../src/portal/components/PortalSearchBar.css | 3 +- .../editor/src/portal/components/Sidebar.css | 105 +++++---- .../editor/src/portal/components/Sidebar.tsx | 47 +--- frontend/editor/src/proprietary/App.tsx | 80 ++++--- .../components/shared/AppSwitcher.tsx | 35 --- .../proprietary/data/processorEntitySearch.ts | 10 +- .../proprietary/data/processorSearchIndex.ts | 10 +- .../editor/src/proprietary/routes/Landing.tsx | 4 + .../routes/adminRouteExtensions.tsx | 8 +- .../src/proprietary/routes/hasPortal.ts | 3 + frontend/editor/src/saas/App.tsx | 97 +++++---- 76 files changed, 2665 insertions(+), 718 deletions(-) create mode 100644 frontend/editor/src/core/components/layout/AppFrame.css create mode 100644 frontend/editor/src/core/components/layout/AppFrame.tsx create mode 100644 frontend/editor/src/core/components/layout/NoAppChrome.tsx create mode 100644 frontend/editor/src/core/components/layout/WorkspaceFrame.css create mode 100644 frontend/editor/src/core/components/notifications/NotificationPanel.tsx delete mode 100644 frontend/editor/src/core/components/shared/AppSwitcher.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandTile.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarHeader.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleButton.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavBrand.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.tsx create mode 100644 frontend/editor/src/core/routes/hasPortal.ts create mode 100644 frontend/editor/src/core/utils/pendingReaderMode.ts create mode 100644 frontend/editor/src/core/utils/viewTransition.test.ts delete mode 100644 frontend/editor/src/desktop/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/desktop/routes/hasPortal.ts delete mode 100644 frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/proprietary/routes/hasPortal.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8271d66dfc..378157818c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4141,7 +4141,6 @@ mobileShort = "Mobile" mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" moreOptions = "More options" -myFiles = "My Files" nextFile = "Next file" noFiles = "No files available" noFilesFound = "No files found matching your search" @@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" -leaveMyFiles = "Leave My Files" +leaveMyFiles = "Leave File library" library = "PDF Library" -myFiles = "My Files" +myFiles = "File library" noFiles = "No files yet" openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" @@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace" allFiles = "All files" back = "Back" backToFolder = "Back to {{folder}}" -backToMyFiles = "Back to My Files" +backToMyFiles = "Back to File library" breadcrumbs = "Folder path" bulkActions = "Actions" cancel = "Cancel" @@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)." moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" -myFiles = "My Files" newFolder = "New folder" newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." newFolderTabUnavailable = "Switch to All or Cloud to create folders." @@ -9173,7 +9171,6 @@ appEditor = "Editor" appProcessor = "Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" -switchApp = "Switch app" [portal.shell.topbar] closeNav = "Close navigation" @@ -9652,6 +9649,16 @@ automate = "Automate" config = "Config" files = "Files" +[quickNav] +editor = "Editor" +home = "Stirling" +invite = "Invite" +landmark = "Quick navigation" +noProcessorAccess = "Ask an admin for processor access" +notifications = "Notifications" +processor = "Processor" +reader = "Reader" + [read] tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 81db0564b8..c51c1fb85d 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; +import { AppFrame } from "@app/components/layout/AppFrame"; import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; @@ -53,18 +54,21 @@ export default function App() { } /> - {/* All other routes need AppProviders for backend integration */} - - - - - - - } - /> + {/* The app, under a shared frame so the rail renders once outside it. */} + }> + {/* All other routes need AppProviders for backend integration */} + + + + + + + } + /> +
); diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx index 6ac3e3c91e..20ebaa191e 100644 --- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx @@ -173,7 +173,7 @@ const FileSourceButtons: React.FC = ({ mb="xs" style={{ paddingLeft: "1rem" }} > - {t("fileManager.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} {buttons} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx index eb7f19170c..b56915b757 100644 --- a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
- {t("filesPage.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")}
diff --git a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts index 5e6292d62f..69a1bc1c53 100644 --- a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts +++ b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts @@ -1,6 +1,6 @@ /** * Stores the route the user came from when they open files into the - * workbench from My Files. Lets the workbench show a "Back to My Files" + * workbench from the file library. Lets the workbench show a "Back to File library" * affordance and return to the exact folder they were browsing. * * Persisted in sessionStorage so a hard reload keeps the return path diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css new file mode 100644 index 0000000000..0cc20b30c6 --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -0,0 +1,18 @@ +/* ========== APP FRAME ========== */ +/* The rail's column, then whichever app is mounted, so a switch changes only the app. */ +.app-frame { + display: flex; + height: 100vh; + height: 100dvh; /* track mobile browser chrome */ + overflow: hidden; + background-color: var(--c-bg); +} + +/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */ +.app-frame__content { + flex: 1; + min-width: 0; + height: 100%; +} + +/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */ diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx new file mode 100644 index 0000000000..38fa92ecba --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext"; +import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost"; +import "@app/components/layout/AppFrame.css"; + +/** The rail renders once outside both apps; Suspense sits inside it, not above. */ +export function AppFrame() { + return ( + +
+ +
+ }> + + +
+
+
+ ); +} diff --git a/frontend/editor/src/core/components/layout/NoAppChrome.tsx b/frontend/editor/src/core/components/layout/NoAppChrome.tsx new file mode 100644 index 0000000000..04416c02f1 --- /dev/null +++ b/frontend/editor/src/core/components/layout/NoAppChrome.tsx @@ -0,0 +1,8 @@ +import { Outlet } from "react-router-dom"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; + +/** Pages that aren't the app: inside the frame for its providers, but with no rail. */ +export function NoAppChrome() { + useSuppressQuickNavRail(); + return ; +} diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index dd2b4a12bd..22d6fdc43c 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -12,10 +12,8 @@ .workbenchBarReopenTab { position: absolute; top: 100%; - /* Right-align with the retract handle inside the bar: the bar's right - margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own - 6px inset. */ - right: calc(var(--nav-gutter) + 15px); + /* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */ + right: 14px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 903c552fd2..0cac20e574 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,4 +1,4 @@ -import { useState, Suspense, lazy } from "react"; +import { useState, useEffect, useRef, Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Loader, Center, Stack, Text } from "@mantine/core"; @@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; +import { useIsPhone } from "@app/hooks/useIsMobile"; import styles from "@app/components/layout/Workbench.module.css"; import WorkbenchBar from "@app/components/shared/WorkbenchBar"; @@ -58,10 +59,13 @@ export default function Workbench() { setPageEditorFunctions, setSidebarsVisible, customWorkbenchViews, + readerMode, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); const { overlay: signingOverlay } = useSigningOverlay(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Get navigation state - this is the source of truth const { selectedTool: selectedToolId } = useNavigationState(); @@ -92,8 +96,20 @@ export default function Workbench() { !isBaseWorkbench(currentView) || // Shared signing drives the viewer from the sidebar with no file in context. (currentView === "viewer" && !!signingOverlay?.file); - const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; - const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; + // Reading hides the bar; the rail's Reader entry is the way back. + const showWorkbenchBar = + topControlsAvailable && hasWorkbenchContent && !readerMode; + const showFloatingSearch = + topControlsAvailable && !hasWorkbenchContent && !readerMode; + + // On the transition, so reading sets the toolbar's start state without locking it. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setViewerToolbarCollapsed(readerMode); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); const handlePreviewClose = () => { setPreviewFile(null); @@ -126,7 +142,7 @@ export default function Workbench() { } } - // The "My Files" workbench is available regardless of whether files are + // The file-library workbench is available regardless of whether files are // currently loaded into the workbench - it lives on top of the IDB store. if (currentView === "myFiles") { return ; @@ -249,10 +265,8 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files, - an empty workbench, a custom view without top controls - it gets its own corner, rather - than those being the places a user cannot see that something of theirs failed. */} - {!showWorkbenchBar && ( + {/* Phone only: above that the rail carries the bell, and here no bar does. */} + {isPhone && !showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/layout/WorkspaceFrame.css b/frontend/editor/src/core/components/layout/WorkspaceFrame.css new file mode 100644 index 0000000000..05cabe95f9 --- /dev/null +++ b/frontend/editor/src/core/components/layout/WorkspaceFrame.css @@ -0,0 +1,16 @@ +/* ========== WORKSPACE FRAME ========== */ +/* Rail and sidebar side by side, full height. Shared by both apps. */ +.workspace-frame { + display: flex; + height: 100%; + flex-shrink: 0; + background-color: var(--c-bg); +} + +/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */ +@media (max-width: 48rem) { + .workspace-frame { + display: block; + height: auto; + } +} diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css index 9352f6bef8..46367a0b2b 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.css +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -10,7 +10,7 @@ position: relative; padding: var(--sp-2, 0.5rem); border: none; - border-radius: var(--radius-md, 0.375rem); + border-radius: var(--radius-md); background: transparent; color: var(--c-text-muted); cursor: pointer; @@ -48,6 +48,12 @@ box-shadow: 0 10px 30px rgb(0 0 0 / 25%); } +/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */ +.notification-bell__panel--rail { + inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter)); + inset-block-end: var(--nav-gutter); +} + .notification-bell__heading { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.875rem; diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx index f2ac1e0a3a..def06eed8d 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -1,27 +1,15 @@ -import { - Fragment, - useEffect, - useId, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BellIcon, Button } from "@app/ui"; -import DividerWithText from "@app/components/shared/DividerWithText"; import { useNotifications } from "@app/hooks/useNotifications"; import { useNotificationActions } from "@app/components/notifications/notificationActions"; -import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import { NotificationPanel } from "@app/components/notifications/NotificationPanel"; import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; import "@app/components/notifications/NotificationBell.css"; -/** - * Renders whatever the server sends without knowing which subsystem produced it or what its actions - * mean, so a new source or failure kind needs no change here. In core because both shells mount it. - */ +/** For the narrow layouts where the rail, which carries the bell, is off screen. */ export function NotificationBell() { - // A build with no notifications API gets no bell at all, rather than one that polls a - // nonexistent endpoint forever to show nothing. + // No API means no bell at all, rather than one polling an endpoint that isn't there. const available = useNotificationsAvailable(); if (!available) return null; return ; @@ -29,14 +17,10 @@ export function NotificationBell() { function MountedNotificationBell() { const { t } = useTranslation(); - const { notifications, unreadCount, documentStateFor, markAllSeen } = - useNotifications(); + const { unreadCount } = useNotifications(); const registry = useNotificationActions(); const [open, setOpen] = useState(false); const container = useRef(null); - const headingId = useId(); - // Where the new ones stop, frozen when the panel opens (opening marks everything read). - const [firstSeenId, setFirstSeenId] = useState(null); // Viewport-fixed, because the workbench bar clips its own overflow. const [anchor, setAnchor] = useState<{ top: number; right: number } | null>( null, @@ -61,54 +45,17 @@ function MountedNotificationBell() { }; }, [open]); - // Opening marks them read, not closing: waiting would leave the badge lit while they read. - const toggle = () => { - setOpen((wasOpen) => { - if (!wasOpen) { - // Before marking, or there is nothing left to read. - setFirstSeenId(notifications[unreadCount]?.id ?? null); - markAllSeen(); - } - return !wasOpen; - }); - }; - - /** - * How many count as new. No boundary id means all of them were; one that has since left the list - * leaves nothing to divide on, so it reads as none rather than guessing at a row. - */ - const boundaryIndex = firstSeenId - ? notifications.findIndex((notification) => notification.id === firstSeenId) - : notifications.length; - const dividedAt = Math.max(0, boundaryIndex); - - useEffect(() => { - if (!open) return; - const closeOnOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (!container.current?.contains(target)) setOpen(false); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", closeOnOutside); - document.addEventListener("keydown", closeOnEscape); - return () => { - document.removeEventListener("mousedown", closeOnOutside); - document.removeEventListener("keydown", closeOnEscape); - }; - }, [open]); - return (
{open && ( -
setOpen(false)} + registry={registry} style={anchor ? { top: anchor.top, right: anchor.right } : undefined} - > -

- {t("notifications.title", "Notifications")} -

- - {notifications.length === 0 ? ( -

- {t("notifications.empty", "Nothing to report.")} -

- ) : ( -
    - {notifications.map((notification, index) => ( - - {index === 0 && dividedAt > 0 && ( -
  • - -
  • - )} - {/* Only with something on both sides: a lone "Earlier" over everything says - nothing the empty badge has not. */} - {index === dividedAt && dividedAt > 0 && ( -
  • - -
  • - )} - setOpen(false)} - /> -
    - ))} -
- )} -
+ /> )}
); diff --git a/frontend/editor/src/core/components/notifications/NotificationPanel.tsx b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx new file mode 100644 index 0000000000..f3ff0c621e --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx @@ -0,0 +1,135 @@ +import { Fragment, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import DividerWithText from "@app/components/shared/DividerWithText"; +import { useNotifications } from "@app/hooks/useNotifications"; +import type { ClientActionRegistry } from "@app/components/notifications/notificationActions"; +import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import "@app/components/notifications/NotificationBell.css"; + +/** Named so a trigger in another tree can point at it with aria-controls. */ +export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel"; + +export interface NotificationPanelProps { + onClose: () => void; + id?: string; + /** Passed in: its document handover has to run whether the panel is open or not. */ + registry: ClientActionRegistry; + style?: React.CSSProperties; + className?: string; +} + +/** Mounted only while open, since mounting is what marks everything read. */ +export function NotificationPanel({ + onClose, + registry, + id, + style, + className, +}: NotificationPanelProps) { + const { t } = useTranslation(); + const { notifications, unreadCount, documentStateFor, markAllSeen } = + useNotifications(); + const panel = useRef(null); + const headingId = useId(); + // Frozen on open, since opening marks them all read. + const [firstSeenId, setFirstSeenId] = useState(null); + + // On mount, not on close: waiting leaves the badge lit while they read. + const marked = useRef(false); + useEffect(() => { + if (marked.current) return; + marked.current = true; + // Before marking, or there is nothing left to divide on. + setFirstSeenId(notifications[unreadCount]?.id ?? null); + markAllSeen(); + }, [notifications, unreadCount, markAllSeen]); + + // No boundary means all were new; one that has left the list means none. + const boundaryIndex = firstSeenId + ? notifications.findIndex((notification) => notification.id === firstSeenId) + : notifications.length; + const dividedAt = Math.max(0, boundaryIndex); + + // Focus goes back to the opener only if it is still inside the panel on close. + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + panel.current?.focus(); + return () => { + if (panel.current?.contains(document.activeElement)) opener?.focus(); + }; + }, []); + + useEffect(() => { + const closeOnOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (panel.current?.contains(target)) return; + // A trigger closes this itself; counting it as outside would reopen it. + if (target.closest?.("[data-notifications-trigger]")) return; + onClose(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index 71d35ed5ad..fbbe85ccdb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps { onSwitch: (app: AppSwitchTarget) => void; } -/** - * The editor / processor items for the app-switch menu. Rendered inside the - * BrandSwitcher's logo dropdown, which both apps use as their switcher. The - * mark is the shared , which recolours itself from the theme - * tokens, so no colour-scheme prop needs threading down here. - */ +/** The editor / processor items for an app-switch menu. */ export function AppSwitchMenuItems({ current, onSwitch, diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx deleted file mode 100644 index aaf55148ef..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Logo } from "@app/ui/Logo"; - -export interface AppSwitcherProps { - /** Icon-only brand mark for the collapsed rail. */ - collapsed?: boolean; -} - -/** - * Sidebar brand header. Core has no admin portal to switch to, so it just - * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) - * shadow this with a version whose logo doubles as the editor⇄processor - * switcher. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css deleted file mode 100644 index dc1659ea15..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.css +++ /dev/null @@ -1,15 +0,0 @@ -/* Logo + app-switch dropdown, shared between the editor and the processor. - The logo itself is the trigger (its mark morphs into a chevron on hover). */ -.sui-brand-switcher { - display: flex; - align-items: center; - flex: 1; - min-width: 0; -} - -/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, - and negative-margin it back so the hover surface still extends past the text. */ -.sui-brand-switcher__trigger.sui-btn { - --button-padding-x: 0.375rem; - margin-inline: -0.375rem; -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx deleted file mode 100644 index 92deb518b2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; - -const meta: Meta = { - title: "Brand/BrandSwitcher", - component: BrandSwitcher, - parameters: { layout: "centered" }, - args: { current: "processor", onSwitch: () => {} }, - argTypes: { - current: { control: "inline-radio", options: ["editor", "processor"] }, - }, -}; -export default meta; -type Story = StoryObj; - -export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx deleted file mode 100644 index 474173fee2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import { Logo } from "@app/ui/Logo"; -import { BrandMark } from "@app/components/shared/BrandMark"; -import { - AppSwitchMenuItems, - type AppSwitchTarget, -} from "@app/components/shared/AppSwitch"; -import "@app/components/shared/BrandSwitcher.css"; - -interface BrandSwitcherProps { - /** The app this is rendered in (shown active in the menu). */ - current: AppSwitchTarget; - /** Called with the selected app (only for the non-current one). */ - onSwitch: (app: AppSwitchTarget) => void; - /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ - collapsed?: boolean; - className?: string; -} - -/** - * Brand lockup that doubles as the editor⇄processor switcher. The whole logo - * is the dropdown trigger: on hover / focus / open the mark morphs into a - * downward chevron (see BrandMark), so no separate chevron button is needed. - * Shared so the editor and the processor present one identical header. - */ -export function BrandSwitcher({ - current, - onSwitch, - collapsed = false, - className, -}: BrandSwitcherProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( -
- - - - - - - - -
- ); -} diff --git a/frontend/editor/src/core/components/shared/BrandTile.tsx b/frontend/editor/src/core/components/shared/BrandTile.tsx new file mode 100644 index 0000000000..e8ccba6db8 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandTile.tsx @@ -0,0 +1,29 @@ +interface BrandTileProps { + /** CSS length. Omit to let the caller's CSS size it. */ + size?: string; + className?: string; +} + +/** The mark in a rounded square. Decorative: call sites carry the accessible name. */ +export function BrandTile({ size, className }: BrandTileProps) { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 2347d9a2b2..76db9deab4 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,7 +1,9 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg); + /* One solid panel, with a rule only on the workbench side, so it frames the document. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; height: 100%; @@ -37,12 +39,19 @@ gap: 0.5rem; } -/* ---- Brand header (logo / editor⇄processor switcher) ---- */ -.file-sidebar-brand { +/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */ +.file-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +/* ---- Header row (wordmark + collapse toggle) ---- */ +.file-sidebar-header { display: flex; align-items: center; - min-height: 40px; - padding: 0 0.375rem; + min-height: var(--nav-header-h); + padding: 0 var(--nav-gutter); flex-shrink: 0; } @@ -51,9 +60,9 @@ flex-shrink: 0; } -.file-sidebar[data-collapsed="true"] .file-sidebar-brand { - flex-direction: column; - gap: 0.25rem; +/* Collapsed the row holds only the toggle, so centre it. */ +.file-sidebar[data-collapsed="true"] .file-sidebar-header { + justify-content: center; padding: 0; } .file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f9337bd880..1dcf4a7301 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; import { useOpenPlan } from "@app/hooks/useOpenPlan"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { @@ -32,8 +31,7 @@ import { useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { AppSwitcher } from "@app/components/shared/AppSwitcher"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; +import { SidebarHeader } from "@app/components/shared/SidebarHeader"; import type { StirlingFileStub } from "@app/types/fileContext"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import "@app/components/shared/FileSidebar.css"; -const COLLAPSED_WIDTH = "3.5rem"; -const EXPANDED_WIDTH = "16.25rem"; // ~260px +// Shared with the processor sidebar via tokens, so the two cannot drift. +const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)"; +const EXPANDED_WIDTH = "var(--sidebar-w)"; // Inlined to avoid a circular import with WatchedFoldersRegistration. const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -98,9 +97,11 @@ export interface FileSidebarProps { collapsed?: boolean; onToggleCollapse?: () => void; onOpenSettings?: () => void; - /** Accessible name override for the toggle button. */ + /** The quick nav rail owns the account control, so the footer drops its own row. */ + accountHoisted?: boolean; + /** Accessible name override for the collapse toggle. */ toggleAriaLabel?: string; - /** Icon override for the toggle button (e.g. back-arrow on /files). */ + /** Icon override for the collapse toggle (e.g. back-arrow on /files). */ toggleIcon?: React.ReactNode; /** Override the Open-from-computer handler (e.g. upload to /files folder). */ onUploadFiles?: (files: File[]) => void | Promise; @@ -155,11 +156,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, + accountHoisted = false, + toggleAriaLabel, + toggleIcon, onUploadFiles, onPickGoogleDriveFiles, extraAction, - toggleAriaLabel, - toggleIcon, }, ref, ) { @@ -249,7 +251,6 @@ const FileSidebar = forwardRef( const { displayName, profilePictureUrl, isAnonymous } = useAccountIdentity(); const credits = useFreeCreditsSummary(); - const otherApp = useOtherAppSwitch(); const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) @@ -943,25 +944,12 @@ const FileSidebar = forwardRef(
)}
-
- - {onToggleCollapse && ( - onToggleCollapse()} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {toggleIcon ?? } - - )} -
+ {/* Box 1 — top controls (open / my files / cloud). No title. File search lives in the global super search (top bar), not here. */} @@ -984,7 +972,7 @@ const FileSidebar = forwardRef( {/* Tooltips only fire when collapsed - when expanded the visible text label below already identifies each row, so a tooltip would just flash a duplicate. Distinct icons (UploadFile for - "Open from computer" vs FolderOpen for "My Files") so the + "Open from computer" vs FolderOpen for "File library") so the collapsed rail isn't two identical folder icons either. */} ( onClick={() => { // "Open from computer" goes straight to the native OS file // picker. The full file manager (recent + drives + folders) - // is reachable via "My Files" below. + // is reachable via "File library" below. nativeFileInputRef.current?.click(); }} role="button" @@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef( )} ( }} role="button" tabIndex={0} - aria-label={t("fileSidebar.myFiles", "My Files")} + aria-label={t("fileSidebar.myFiles", "File library")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef( {!collapsed && ( - {t("fileSidebar.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} )}
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — the shared footer: credits, app switch, account row. */} + {/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
diff --git a/frontend/editor/src/core/components/shared/SidebarHeader.tsx b/frontend/editor/src/core/components/shared/SidebarHeader.tsx new file mode 100644 index 0000000000..1a2befa074 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarHeader.tsx @@ -0,0 +1,33 @@ +import { Logo } from "@app/ui/Logo"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; + +export interface SidebarHeaderProps { + collapsed?: boolean; + onToggleCollapse?: () => void; + toggleAriaLabel?: string; + toggleIcon?: React.ReactNode; + className?: string; +} + +/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */ +export function SidebarHeader({ + collapsed, + onToggleCollapse, + toggleAriaLabel, + toggleIcon, + className, +}: SidebarHeaderProps) { + return ( +
+ {!collapsed && } + {onToggleCollapse && ( + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx new file mode 100644 index 0000000000..3a1345360b --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; + +export interface SidebarToggleButtonProps { + collapsed?: boolean; + onToggle: () => void; + ariaLabel?: string; + icon?: React.ReactNode; +} + +/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */ +export function SidebarToggleButton({ + collapsed, + onToggle, + ariaLabel, + icon, +}: SidebarToggleButtonProps) { + const { t } = useTranslation(); + return ( + onToggle()} + aria-label={ + ariaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) + } + > + {icon ?? } + + ); +} diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 55c06a1533..b8128bf637 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; import { TooltipTip } from "@app/types/tips"; import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; -import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useOptionalSidebarContext } from "@app/contexts/SidebarContext"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; @@ -59,6 +59,29 @@ export interface TooltipProps { showCloseButton?: boolean; } +/** Split out so only tooltips with a header need the logo and the providers behind it. */ +function TooltipHeader({ + header, +}: { + header: NonNullable; +}) { + const { tooltipLogo } = useLogoAssets(); + return ( +
+
+ {header.logo || ( + Stirling PDF + )} +
+ {header.title} +
+ ); +} + export const Tooltip: React.FC = ({ sidebarTooltip = false, position, @@ -85,7 +108,6 @@ export const Tooltip: React.FC = ({ const { t } = useTranslation(); const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); - const { tooltipLogo } = useLogoAssets(); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -105,9 +127,9 @@ export const Tooltip: React.FC = ({ }, []); // Always call the hook unconditionally to satisfy React's rules of hooks. - // The context is only used when sidebarTooltip is true. - const sidebarContextValue = useSidebarContext(); - const sidebarContext = sidebarTooltip ? sidebarContextValue : null; + // Optional: the plain tooltip renders outside the provider. + const sidebarContextValue = useOptionalSidebarContext(); + const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null; const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; @@ -443,20 +465,7 @@ export const Tooltip: React.FC = ({ } /> )} - {header && ( -
-
- {header.logo || ( - Stirling PDF - )} -
- {header.title} -
- )} + {header && } - {/* Left: optional "Back to My Files" + view switcher */} + {/* Left: optional "Back to File library" + view switcher */}
{returnRoute && hasFiles && ( <> @@ -501,7 +503,7 @@ export default function WorkbenchBar({ : "filesPage.backToMyFiles", returnRoute.label ? `Back to ${returnRoute.label}` - : "Back to My Files", + : "Back to File library", { folder: returnRoute.label ?? "" }, )} leftSection={} @@ -511,7 +513,7 @@ export default function WorkbenchBar({ ? t("filesPage.backToFolder", "Back to {{folder}}", { folder: returnRoute.label, }) - : t("filesPage.backToMyFiles", "Back to My Files")} + : t("filesPage.backToMyFiles", "Back to File library")}
@@ -603,9 +605,13 @@ export default function WorkbenchBar({ enforcingProgress={enforcingProgress} /> )} - {/* Last in the globals, so it is the rightmost control. */} -
- + {isPhone && ( + <> + {/* Last in the globals, so it is the rightmost control. */} +
+ + + )}
); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx index 373c91bccf..848c0cb4a5 100644 --- a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -33,6 +33,8 @@ export interface NavFooterProps { otherApp?: NavFooterAppLink | null; /** Extra rows above the account row (the self-hosted link-account CTA). */ accountExtras?: ReactNode; + /** False where the rail owns the account control, so only one avatar is drawn. */ + showAccount?: boolean; /** Icon-rail state: labels collapse to tooltips. */ collapsed?: boolean; className?: string; @@ -69,6 +71,7 @@ export function NavFooter({ onOpenPlan, otherApp, accountExtras, + showAccount = true, collapsed = false, className, }: NavFooterProps) { @@ -144,49 +147,51 @@ export function NavFooter({ }); } - rows.push({ - key: "account", - node: ( - - - - ), - }); + {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( + + + + )} + + + ), + }); + } + + if (rows.length === 0) return null; return ( void; +} + +export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) { + const { t } = useTranslation(); + const label = t("quickNav.home", "Stirling"); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx new file mode 100644 index 0000000000..9a9101a826 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { + NotificationPanel, + NOTIFICATIONS_PANEL_ID, +} from "@app/components/notifications/NotificationPanel"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount"; +import { + useRegisterQuickNavHost, + type QuickNavToolReasons, +} from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +export interface QuickNavHostBridgeProps { + portalAccess?: boolean; + readerMode?: boolean; + onSetReaderMode?: (on: boolean) => void; + onOpenSettings: () => void; + requestNavigation?: (go: () => void) => void; + onGoToDefaultState?: () => void; + onSelectTool?: (toolId: ToolId) => void; + /** Merged over the reasons worked out here, for what only the app can see. */ + toolReasons?: QuickNavToolReasons; +} + +/** Registers with the rail what only the app can see, and owns the notifications panel. */ +export function QuickNavHostBridge({ + portalAccess = false, + readerMode = false, + onSetReaderMode, + onOpenSettings, + requestNavigation, + onSelectTool, + onGoToDefaultState, + toolReasons, +}: QuickNavHostBridgeProps) { + const { displayName, profilePictureUrl } = useAccountIdentity(); + const signingBadge = useSigningBadgeCount(); + const notificationsAvailable = useNotificationsAvailable(); + // Built even when closed: it carries a one-shot document pickup that would sit unclaimed. + const notificationActions = useNotificationActions(); + const endpointReasons = useQuickNavToolReasons(); + const mergedToolReasons = useMemo(() => { + // An empty map from the app is silence, not an answer. + const extra = + toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null; + if (!endpointReasons && !extra) return undefined; + return { ...endpointReasons, ...extra }; + }, [endpointReasons, toolReasons]); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const closeNotifications = useCallback(() => setNotificationsOpen(false), []); + + useRegisterQuickNavHost( + { + identity: { displayName, profilePictureUrl }, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons: mergedToolReasons, + }, + { + openSettings: onOpenSettings, + requestNavigation, + selectTool: onSelectTool, + setReaderMode: onSetReaderMode, + goToDefaultState: onGoToDefaultState, + toggleNotifications: () => setNotificationsOpen((open) => !open), + }, + ); + + // Mounted only while open, so a closed panel never subscribes to the poll. + if (!notificationsAvailable || !notificationsOpen) return null; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css new file mode 100644 index 0000000000..4d6845f592 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css @@ -0,0 +1,173 @@ +/* ========== QUICK NAV RAIL ========== */ + +.quick-nav-rail { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; +} + +.quick-nav-rail-group { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + flex-shrink: 0; + width: 100%; +} + +/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */ +.quick-nav-rail-item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 2.25rem; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + transition: + background-color var(--motion-fast), + color var(--motion-fast); +} + +.quick-nav-rail-item svg, +.quick-nav-rail-item img { + width: 1.125rem; + height: 1.125rem; + color: inherit; + fill: currentColor; +} + +/* Taller than wide (71x79), so height drives the width. */ +.quick-nav-rail-item .sui-brandmark { + width: auto; + height: 1.125rem; +} + +.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark, +.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] { + filter: grayscale(1); +} + +.quick-nav-rail-item:hover { + background: var(--c-hover); + color: var(--c-text); +} + +/* Opacity rather than a colour: there is no disabled-text token. */ +.quick-nav-rail-item[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} +.quick-nav-rail-item[aria-disabled="true"]:hover { + background: transparent; + color: var(--c-primary); +} + +.quick-nav-rail-item:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +.quick-nav-rail-badge { + position: absolute; + top: 0.125rem; + inset-inline-end: 0.125rem; + min-width: 0.875rem; + height: 0.875rem; + padding: 0 0.1875rem; + border-radius: var(--radius-pill); + /* The solid step: 9px numerals need the darker end of the ramp. */ + background: var(--c-danger-solid); + color: var(--c-text-on-primary); + font-size: 0.5625rem; + font-weight: var(--font-weight-semibold); + line-height: 0.875rem; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.quick-nav-rail-badge[data-tone="warning"] { + background: var(--c-warning-solid); +} + +/* Top margin only: the group below supplies the other half, centring the rule. */ +.quick-nav-rail-divider { + width: 100%; + height: 0; + margin: var(--quicknav-item-gap) 0 0; + border: 0; + border-top: 1px solid var(--c-border); +} + +.quick-nav-rail-footer { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + width: 100%; + flex-shrink: 0; +} + +/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */ +.quick-nav-brand { + width: 100%; + height: var(--nav-header-h); + flex-shrink: 0; + /* The bar's inset supplies part of the gap; only the remainder is added here. */ + margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad)); +} + +.quick-nav-brand-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.quick-nav-brand-button:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; + border-radius: var(--radius-md); +} + +/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */ +.quick-nav-rail-item[aria-current="true"], +.quick-nav-rail-item[aria-current="true"]:hover, +.quick-nav-rail-item[aria-pressed="true"], +.quick-nav-rail-item[aria-pressed="true"]:hover { + background: var(--c-text); + color: var(--c-surface); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover, +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-current="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-pressed="true"]:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css new file mode 100644 index 0000000000..c12a6a1f71 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css @@ -0,0 +1,29 @@ +/* The account control, pinned to the bottom of the bar. */ + +.quick-nav-rail-account { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + /* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */ + margin-top: var(--space-2); + /* Matches the slack centring the brand mark leaves at the top. */ + padding-bottom: 0.3125rem; +} + +.quick-nav-rail-avatar-target { + display: inline-flex; +} + +/* Appearance comes from the shared Avatar; only the button reset is ours. */ +.quick-nav-rail-avatar { + border: none; + padding: 0; + user-select: none; +} + +.quick-nav-rail-avatar:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx new file mode 100644 index 0000000000..1404925de6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { Avatar } from "@app/ui/Avatar"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import "@app/components/shared/quickNav/QuickNavRailAccount.css"; + +export interface QuickNavRailAccountProps { + onOpenSettings: () => void; + /** Null between apps; the disc still renders, so the bar keeps its shape. */ + identity: QuickNavIdentity | null; +} + +/** The avatar opens settings, so there is no separate gear beside it. */ +export function QuickNavRailAccount({ + onOpenSettings, + identity, +}: QuickNavRailAccountProps) { + const { t } = useTranslation(); + const displayName = + identity?.displayName ?? t("auth.displayName.user", "User"); + const profilePictureUrl = identity?.profilePictureUrl ?? null; + const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`; + + return ( +
+ + {/* A span, not the Avatar: Tooltip binds by cloning its child. */} + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx new file mode 100644 index 0000000000..328f2d8f4e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { + QuickNavRailBase, + type QuickNavEntry, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +/** The rail needs no providers. */ +function withProviders(ui: React.ReactNode) { + return <>{ui}; +} + +function entry( + id: string, + overrides: Partial = {}, +): QuickNavEntry { + return { + id, + label: id, + icon: null, + onClick: () => {}, + ...overrides, + }; +} + +const PROCESSOR = entry("processor"); +const WITHIN = [entry("files"), entry("reader")]; + +function renderRail(groups: QuickNavEntry[][]) { + const { container } = render( + withProviders(), + ); + return { + labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) => + b.getAttribute("aria-label"), + ), + dividers: container.querySelectorAll(".quick-nav-rail-divider").length, + }; +} + +describe("QuickNavRailBase — groups", () => { + it("divides one group from the next", () => { + const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]); + + expect(labels).toEqual(["processor", "files", "reader"]); + expect(dividers).toBe(1); + }); + + it("drops an empty group, and the divider with it", () => { + const { labels, dividers } = renderRail([[], WITHIN]); + + expect(labels).toEqual(["files", "reader"]); + expect(dividers).toBe(0); + }); +}); + +describe("QuickNavRailBase — entry state", () => { + it("reports on/off for a toggle and nothing for the rest", () => { + // Nothing here is a view you occupy, so only a real toggle has state. + const { container } = render( + withProviders( + , + ), + ); + + const state = [...container.querySelectorAll(".quick-nav-rail-item")].map( + (b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")], + ); + expect(state).toEqual([ + ["processor", null], + ["reader", "true"], + ["files", null], + ]); + expect(container.querySelectorAll("[aria-current]")).toHaveLength(0); + }); + + it("keeps a disabled entry in the tab order so its reason stays reachable", () => { + // The tooltip carrying the reason is only reachable while it can be focused. + const { container } = render( + withProviders( + , + ), + ); + + const automate = container.querySelector('[aria-label="automate"]')!; + expect(automate.getAttribute("aria-disabled")).toBe("true"); + expect(automate.hasAttribute("disabled")).toBe(false); + }); + + it("keeps an unavailable entry rendered, disabled rather than dropped", () => { + // Slots must not appear and vanish as access resolves. + const { container } = render( + withProviders( + , + ), + ); + + const processor = container.querySelector('[aria-label="processor"]'); + expect(processor).not.toBeNull(); + expect(processor?.getAttribute("aria-disabled")).toBe("true"); + // aria-disabled, not the disabled attribute: it stays focusable for its tooltip. + expect(processor?.hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx new file mode 100644 index 0000000000..5c0be45db0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import "@app/components/shared/quickNav/QuickNavRail.css"; + +export type QuickNavTarget = "reader" | "editor" | "files" | "processor"; + +export interface QuickNavEntry { + id: string; + label: string; + icon: ReactNode; + /** The app you are in, drawn with an edge bar. */ + current?: boolean; + /** Only for entries that toggle something; use `current` for the app you are in. */ + pressed?: boolean; + /** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */ + disabled?: boolean; + reason?: string; + badge?: number; + /** Popup semantics for an entry whose panel is rendered in another tree. */ + expanded?: boolean; + controls?: string; + /** "danger" waits on the user; "warning" is awareness only. */ + badgeTone?: "danger" | "warning"; + onClick: () => void; +} + +export interface QuickNavRailBaseProps { + /** Divided by a rule; empty groups are dropped. */ + groups: QuickNavEntry[][]; + footer?: ReactNode; +} + +/** Exported so footer entries reuse it rather than a lookalike. */ +export function RailButton({ + label, + icon, + pressed, + disabled, + reason, + badge, + badgeTone = "danger", + current, + expanded, + controls, + onClick, +}: Omit) { + return ( + + + + ); +} + +export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) { + const { t } = useTranslation(); + const populated = groups.filter((entries) => entries.length > 0); + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css new file mode 100644 index 0000000000..606c2c3831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css @@ -0,0 +1,38 @@ +.quick-nav-rail-container { + /* On the column, so the parts outside the nav inherit them too. */ + --quicknav-item-gap: var(--space-3); + --quicknav-surface-pad: 0.375rem; + + width: var(--nav-rail-w); + height: 100%; + flex-shrink: 0; + box-sizing: border-box; + /* On the column, not the bar, so the fill covers the gutters too. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); + display: flex; + flex-direction: column; + padding-block: var(--nav-gutter); + padding-inline: calc(var(--nav-gutter) / 2); +} + +/* Child selector to beat .sui-nav-surface, which would win on order. */ +.quick-nav-rail-container > .quick-nav-rail-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +.quick-nav-rail-surface { + flex: 1; + min-height: 0; + /* No inline padding: the bar is one target wide and would squeeze the buttons. */ + padding: var(--quicknav-surface-pad) 0; +} + +/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */ +@media (max-width: 48rem) { + .quick-nav-rail-container { + display: none; + } +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx new file mode 100644 index 0000000000..154700518e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { NavSurface } from "@app/ui/NavSurface"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import { + QuickNavRailBase, + RailButton, + type QuickNavRailBaseProps, +} from "@app/components/shared/quickNav/QuickNavRailBase"; +import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; +import "@app/components/shared/quickNav/QuickNavRailContainer.css"; + +export type { + QuickNavEntry, + QuickNavTarget, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +export interface QuickNavRailContainerProps extends Omit< + QuickNavRailBaseProps, + "footer" +> { + /** The rail owns the account control, so the sidebars drop their own row. */ + onOpenSettings?: () => void; + /** Omitted in builds with no processor to invite anyone into. */ + onInvite?: () => void; + onToggleNotifications?: () => void; + notificationsOpen?: boolean; + identity?: QuickNavIdentity | null; + onReturnHome: () => void; +} + +/** The fixed-width column the rail sits in. */ +export function QuickNavRailContainer({ + onOpenSettings, + onInvite, + onToggleNotifications, + notificationsOpen, + identity = null, + onReturnHome, + ...railProps +}: QuickNavRailContainerProps) { + const { t } = useTranslation(); + return ( +
+ + + + + {onInvite && ( + + } + onClick={onInvite} + /> + )} + {onOpenSettings && ( + + )} +
+ } + /> +
+
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx new file mode 100644 index 0000000000..5c748b36cc --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -0,0 +1,178 @@ +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer"; +import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase"; +import type { ToolId } from "@app/types/toolId"; +import { useQuickNavHost } from "@app/contexts/QuickNavHostContext"; +import { requestReaderMode } from "@app/utils/pendingReaderMode"; +import { + saveEditorReturnPath, + takeEditorReturnPath, +} from "@app/services/workbenchSession"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; + +const SIZE = "1.125rem"; + +/** Entries come from the URL, not either app's context, so the rail survives a switch. */ +export function QuickNavRailHost() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname, search } = useLocation(); + const host = useQuickNavHost(); + + const appMounted = Boolean(host?.appMounted); + + const inPortal = pathname.startsWith(PORTAL_BASENAME); + + // Only the app knows its own default state. + const returnHome = () => { + const reset = host?.actions.current?.goToDefaultState; + if (reset) reset(); + else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME); + }; + + // Guarded where the app supplies a guard, so leaving mid-edit still prompts. + const go = (to: string) => { + const guard = host?.actions.current?.requestNavigation; + if (guard) guard(() => navigate(to)); + else navigate(to); + }; + + // Through the app where possible: its route only selects a tool on a fresh mount. + const openTool = (toolId: ToolId, route: string) => { + const select = host?.actions.current?.selectTool; + if (select) select(toolId); + else go(route); + }; + + const unusable = (id: ToolId) => { + const reason = host?.toolReasons?.[id]; + return { disabled: Boolean(reason), reason }; + }; + + const apps: QuickNavEntry[] = [ + { + id: "processor", + label: t("quickNav.processor", "Processor"), + // Two literals, not a computed name: the offline icon bundle scans for `icon="..."`. + icon: inPortal ? ( + + ) : ( + + ), + current: inPortal, + disabled: HAS_PORTAL && !inPortal && !host?.portalAccess, + reason: + HAS_PORTAL && !inPortal && !host?.portalAccess + ? t("quickNav.noProcessorAccess", "Ask an admin for processor access") + : undefined, + onClick: () => { + if (inPortal) { + returnHome(); + return; + } + saveEditorReturnPath(pathname + search); + go(PORTAL_BASENAME); + }, + }, + { + id: "editor", + label: t("quickNav.editor", "Editor"), + icon: inPortal ? ( + + ) : ( + + ), + current: !inPortal, + onClick: () => { + if (!inPortal) { + returnHome(); + return; + } + // Back to where you left the editor, not its front door. + navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); + }, + }, + ]; + + const within: QuickNavEntry[] = [ + { + id: "files", + label: t("fileSidebar.myFiles", "File library"), + icon: ( + + ), + onClick: () => go("/files"), + }, + { + id: "reader", + label: t("quickNav.reader", "Reader"), + icon: ( + + ), + pressed: Boolean(host?.readerMode), + // From the processor there is no editor to toggle - see pendingReaderMode. + onClick: () => { + const setMode = host?.actions.current?.setReaderMode; + if (setMode) { + setMode(!host?.readerMode); + return; + } + requestReaderMode(); + go(EDITOR_BASENAME); + }, + }, + { + id: "automate", + label: t("quickAccess.automate", "Automate"), + icon: ( + + ), + ...unusable("automate"), + onClick: () => openTool("automate", "/automate"), + }, + { + id: "sharedSign", + label: t("home.sharedSign.title", "Shared Signing"), + icon: ( + + ), + badge: host?.signingBadge, + badgeTone: "warning", + ...unusable("sharedSign"), + onClick: () => openTool("sharedSign", "/shared-sign"), + }, + ]; + + // Read at click time, so it's always the mounted app's. + const openSettings = () => host?.actions.current?.openSettings?.(); + + // A route that isn't the app hides the bar - see useSuppressQuickNavRail. + if (!appMounted || host?.chromeless) return null; + + return ( + go(`${PORTAL_BASENAME}/users`) + : undefined + } + onToggleNotifications={() => + host?.actions.current?.toggleNotifications?.() + } + notificationsOpen={host?.notificationsOpen} + /> + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx new file mode 100644 index 0000000000..800eb55b5b --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AppNotification } from "@app/services/notifications"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: () => Promise.resolve(false), +})); + +const h = vi.hoisted(() => ({ notificationsAvailable: true })); + +vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({ + useNotificationsAvailable: () => h.notificationsAvailable, +})); + +function notification(id: string): AppNotification { + return { + id, + kind: "PIPELINE_FAILED", + title: id, + createdAt: "2026-01-01T00:00:00Z", + fileId: null, + sourceId: null, + count: 1, + actions: [], + } as unknown as AppNotification; +} + +describe("QuickNavRailNotifications", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications.mockReset().mockResolvedValue([]); + h.notificationsAvailable = true; + }); + + it("keeps out of a build with no notifications API, and off its timer", async () => { + // No endpoint to poll and nothing it could show. + h.notificationsAvailable = false; + + const { container } = render( + {}} />, + ); + + await Promise.resolve(); + expect(container.querySelector(".quick-nav-rail-item")).toBeNull(); + expect(fetchNotifications).not.toHaveBeenCalled(); + }); + + it("carries the unread count on the icon", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + + render( {}} />); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("asks the mounted app to open the panel rather than opening one itself", async () => { + const onToggle = vi.fn(); + const { container } = render( + , + ); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + fireEvent.click(container.querySelector(".quick-nav-rail-item")!); + + expect(onToggle).toHaveBeenCalledTimes(1); + // No panel of its own: a row's actions would have no workbench to act on. + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays pressable before an app has registered, doing nothing", async () => { + // Between apps there is briefly no handler. + const { container } = render(); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + const button = container.querySelector(".quick-nav-rail-item")!; + expect(() => fireEvent.click(button)).not.toThrow(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx new file mode 100644 index 0000000000..aee294de1c --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel"; + +export interface QuickNavRailNotificationsProps { + onToggle?: () => void; + /** Whether the app's panel is open, which this button reports but does not own. */ + open?: boolean; +} + +/** The count is read here; the app owns the panel - see NotificationPanel. */ +export function QuickNavRailNotifications({ + onToggle, + open = false, +}: QuickNavRailNotificationsProps) { + // Gated before the count is read: subscribing starts the poll. + const available = useNotificationsAvailable(); + if (!available) return null; + return ; +} + +function MountedRailNotifications({ + onToggle, + open, +}: QuickNavRailNotificationsProps) { + const { t } = useTranslation(); + const { unreadCount } = useNotifications(); + + return ( + // Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed. + + + } + badge={unreadCount} + expanded={Boolean(open)} + controls={NOTIFICATIONS_PANEL_ID} + onClick={() => onToggle?.()} + /> + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx new file mode 100644 index 0000000000..6295ddde66 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; + +const h = vi.hoisted(() => ({ + endpointStatus: {} as Record, + endpointDetails: {} as Record, + loading: false, + configLoading: false, + groupSigningEnabled: true, +})); + +vi.mock("@app/hooks/useEndpointConfig", () => ({ + useMultipleEndpointsEnabled: () => ({ + endpointStatus: h.endpointStatus, + endpointDetails: h.endpointDetails, + loading: h.loading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ + config: null, + loading: h.configLoading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/hooks/useGroupSigningEnabled", () => ({ + useGroupSigningEnabled: () => h.groupSigningEnabled, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +describe("useQuickNavToolReasons", () => { + beforeEach(() => { + window.localStorage.clear(); + h.endpointStatus = {}; + h.endpointDetails = {}; + h.loading = false; + h.configLoading = false; + h.groupSigningEnabled = true; + }); + + it("admits it does not know rather than reporting nothing wrong", () => { + h.loading = true; + h.endpointStatus = { automate: false }; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); + + it("reports what it last knew while the answer is being fetched again", () => { + // Each app has its own query cache, and a reload has none at all. + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.loading = true; + h.endpointStatus = {}; + h.endpointDetails = {}; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("forgets a reason once the server stops reporting it", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.endpointStatus = { automate: true }; + h.endpointDetails = {}; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + + // The cleared state, not the old reason, is what a reload reads back. + h.loading = true; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("says nothing about an endpoint the server reports as available", () => { + h.endpointStatus = { automate: true }; + + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("blames the administrator when the endpoint was turned off by config", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + // The tool picker's label with its trailing colon stripped. + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("blames the missing dependency when that is what the server said", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "DEPENDENCY" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe( + "Unavailable - required tool missing on server", + ); + }); + + it("greys out shared signing when the server has the feature switched off", () => { + // A whole feature rather than a removable endpoint, so it has its own signal. + h.groupSigningEnabled = false; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.sharedSign).toBe( + "Collaborative signing isn't enabled on this server", + ); + }); + + it("waits for the config before judging shared signing", () => { + // The config loads separately and reads as "off" before it arrives. + h.configLoading = true; + h.groupSigningEnabled = false; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts new file mode 100644 index 0000000000..235f4d4f08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { getDisabledLabel } from "@app/components/tools/fullscreen/shared"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +const ENTRY_ENDPOINTS = { + automate: ["automate"], +} satisfies Partial>; + +// Object.keys widens to string, which a tool-id-keyed record can't be indexed by. +const ENDPOINT_ENTRIES = Object.keys( + ENTRY_ENDPOINTS, +) as (keyof typeof ENTRY_ENDPOINTS)[]; + +/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */ +type EndpointCause = "missingDependency" | "disabledByAdmin"; +type Cause = EndpointCause | "groupSigningOff"; +type Causes = Partial>; +const CAUSES: Cause[] = [ + "missingDependency", + "disabledByAdmin", + "groupSigningOff", +]; + +/** Causes, not sentences, so a language change can't resurrect stale text. */ +const STORAGE_KEY = "stirling.quickNav.toolCauses"; + +function readRemembered(): Causes | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const known = Object.entries(parsed as Record).filter( + ([, cause]) => CAUSES.includes(cause as Cause), + ) as [ToolId, Cause][]; + return Object.fromEntries(known); + } catch { + return null; + } +} + +function remember(causes: Causes): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes)); + } catch { + // Won't survive the next reload. + } +} + +function causesFor( + endpointStatus: Record, + endpointDetails: Record, +): Causes { + const causes: Causes = {}; + for (const entry of ENDPOINT_ENTRIES) { + const off = ENTRY_ENDPOINTS[entry].filter( + (name) => endpointStatus[name] === false, + ); + if (off.length === 0) continue; + causes[entry] = off.some( + (name) => endpointDetails[name]?.reason === "DEPENDENCY", + ) + ? "missingDependency" + : "disabledByAdmin"; + } + return causes; +} + +/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */ +export function useQuickNavToolReasons(): QuickNavToolReasons | null { + const { t } = useTranslation(); + const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []); + const { endpointStatus, endpointDetails, loading } = + useMultipleEndpointsEnabled(endpoints); + + // Read once: later reads would fight the live answer. + const [remembered] = useState(readRemembered); + + const { loading: configLoading } = useAppConfig(); + const groupSigningEnabled = useGroupSigningEnabled(); + + const live = useMemo(() => { + // A half answer would dim entries it can't see yet. + if (loading || configLoading) return null; + const causes = causesFor(endpointStatus, endpointDetails); + if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff"; + return causes; + }, [ + loading, + configLoading, + endpointStatus, + endpointDetails, + groupSigningEnabled, + ]); + + // Keyed on contents: the object is rebuilt every render. + const liveKey = live ? JSON.stringify(live) : null; + useEffect(() => { + if (liveKey) remember(JSON.parse(liveKey) as Causes); + }, [liveKey]); + + const causes = live ?? remembered; + + return useMemo(() => { + if (!causes) return null; + const reasons: QuickNavToolReasons = {}; + for (const entry of Object.keys(causes) as ToolId[]) { + const cause = causes[entry]; + if (cause === "groupSigningOff") { + // The tool's own wording, minus the full stop. + reasons[entry] = t( + "sharedSign.disabledBody", + "Collaborative signing isn't enabled on this server.", + ).replace(/\.\s*$/, ""); + continue; + } + if (!cause) continue; + // These labels normally sit in front of a tool name, hence the trailing colon. + const { key, fallback } = getDisabledLabel(cause); + reasons[entry] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [causes, t]); +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index d66d30c442..031ecd8e20 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -115,7 +115,7 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; - if (!isPanelVisible) return "3.5rem"; + if (!isPanelVisible) return "var(--nav-rail-w)"; return expandedWidth; }; @@ -181,7 +181,8 @@ export default function RightSidebar() { content={tool.name} position="left" arrow - delay={300} + // No delay: collapsed to icons, the tooltip is the only label. + delay={0} > ( - - - -
+ +
+ {/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */} + -
-
- -
- -
-
- - + +
+
+ +
+ +
+
+
), }, { diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx new file mode 100644 index 0000000000..fc648cbfe5 --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + QuickNavHostProvider, + useQuickNavHost, + useRegisterQuickNavHost, + useSuppressQuickNavRail, +} from "@app/contexts/QuickNavHostContext"; + +function Probe({ onRead }: { onRead: (value: unknown) => void }) { + const host = useQuickNavHost(); + onRead({ + appMounted: host?.appMounted, + chromeless: host?.chromeless, + identity: host?.identity, + openSettings: Boolean(host?.actions.current?.openSettings), + }); + return null; +} + +function App() { + useRegisterQuickNavHost( + { identity: { displayName: "Ada", profilePictureUrl: null } }, + { openSettings: () => {} }, + ); + return null; +} + +function LoginRoute() { + useSuppressQuickNavRail(); + return null; +} + +function setup() { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} /> + + , + ); + return { view, read: () => latest }; +} + +describe("QuickNavHostContext", () => { + it("keeps what the app published after it unmounts, but drops its handlers", () => { + // Data survives the gap between one app unmounting and the next registering. + const { view, read } = setup(); + + expect(read().appMounted).toBe(true); + expect(read().identity).toEqual({ + displayName: "Ada", + profilePictureUrl: null, + }); + expect(read().openSettings).toBe(true); + + view.rerender( + + {}} /> + , + ); + + // Re-read through a fresh probe in the same provider. + let after: Record = {}; + view.rerender( + + (after = value as Record)} /> + , + ); + expect(after.appMounted).toBe(true); + expect(after.openSettings).toBe(false); + }); + + it("hides the bar while a route with no app chrome is on screen", () => { + // appMounted is sticky, so it can't answer "is an app on screen now". + const { view, read } = setup(); + expect(read().chromeless).toBe(false); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let during: Record = {}; + view.rerender( + + (during = value as Record)} + /> + + + , + ); + expect(during.chromeless).toBe(true); + }); + + it("brings the bar back when that route leaves", () => { + const { view } = setup(); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let after: Record = {}; + act(() => { + view.rerender( + + (after = value as Record)} + /> + + , + ); + }); + expect(after.chromeless).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx new file mode 100644 index 0000000000..540ec8cd6c --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -0,0 +1,201 @@ +import type { ToolId } from "@app/types/toolId"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +export type QuickNavToolReasons = Partial>; + +export interface QuickNavIdentity { + displayName: string; + profilePictureUrl: string | null; +} + +export interface QuickNavHostData { + /** Sticky: one app unmounts before the next one registers. */ + appMounted: boolean; + identity: QuickNavIdentity | null; + signingBadge: number; + portalAccess: boolean; + readerMode: boolean; + /** The app owns the panel; the rail's bell only reports its state. */ + notificationsOpen: boolean; + /** Translated; absent means usable. */ + toolReasons: QuickNavToolReasons; + /** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */ + hasSettings: boolean; +} + +export interface QuickNavHostActions { + openSettings?: () => void; + /** The editor reads its tool from the URL only on mount. */ + selectTool?: (toolId: ToolId) => void; + setReaderMode?: (on: boolean) => void; + toggleNotifications?: () => void; + goToDefaultState?: () => void; + requestNavigation?: (go: () => void) => void; +} + +interface QuickNavHostValue extends QuickNavHostData { + /** Reset on unmount, unlike the data above. */ + chromeless: boolean; + setChromeless: (chromeless: boolean) => void; + /** A ref, so a click reaches the app currently mounted. */ + actions: React.RefObject; + setData: (data: Partial) => void; + setActions: (actions: QuickNavHostActions) => void; +} + +const EMPTY_REASONS: QuickNavToolReasons = {}; + +const EMPTY_DATA: QuickNavHostData = { + appMounted: false, + toolReasons: EMPTY_REASONS, + identity: null, + signingBadge: 0, + portalAccess: false, + readerMode: false, + notificationsOpen: false, + hasSettings: false, +}; + +function sameReasons( + next: QuickNavToolReasons, + prev: QuickNavToolReasons, +): boolean { + const nextKeys = Object.keys(next); + if (nextKeys.length !== Object.keys(prev).length) return false; + return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]); +} + +const QuickNavHostContext = createContext(null); + +/** Outside both apps' providers, so each app registers what only it knows. */ +export function QuickNavHostProvider({ children }: { children: ReactNode }) { + const [data, setDataState] = useState(EMPTY_DATA); + const [chromeless, setChromelessState] = useState(false); + const actions = useRef({}); + + const setData = useCallback((next: Partial) => { + setDataState((prev) => { + const merged = { ...prev, ...next }; + const unchanged = + merged.appMounted === prev.appMounted && + merged.signingBadge === prev.signingBadge && + merged.portalAccess === prev.portalAccess && + merged.readerMode === prev.readerMode && + merged.notificationsOpen === prev.notificationsOpen && + merged.hasSettings === prev.hasSettings && + merged.identity?.displayName === prev.identity?.displayName && + merged.identity?.profilePictureUrl === + prev.identity?.profilePictureUrl && + // Compared by value: the object is rebuilt every render. + sameReasons(merged.toolReasons, prev.toolReasons); + return unchanged ? prev : merged; + }); + }, []); + + const setActions = useCallback((next: QuickNavHostActions) => { + actions.current = next; + }, []); + + const setChromeless = useCallback((next: boolean) => { + setChromelessState(next); + }, []); + + const value = useMemo( + () => ({ + ...data, + chromeless, + actions, + setData, + setActions, + setChromeless, + }), + [data, chromeless, setData, setActions, setChromeless], + ); + + return ( + + {children} + + ); +} + +export function useQuickNavHost(): QuickNavHostValue | null { + return useContext(QuickNavHostContext); +} + +/** No-ops outside the provider. */ +export function useRegisterQuickNavHost( + data: Partial, + actions: QuickNavHostActions, +): void { + const host = useQuickNavHost(); + const { + identity, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + } = data; + const hasSettings = Boolean(actions.openSettings); + + useEffect(() => { + host?.setData({ + appMounted: true, + identity: identity ?? null, + signingBadge: signingBadge ?? 0, + portalAccess: portalAccess ?? false, + readerMode: readerMode ?? false, + notificationsOpen: notificationsOpen ?? false, + // Omitted when unknown, so the last answer survives a re-fetch. + ...(toolReasons ? { toolReasons } : {}), + hasSettings, + }); + // By field: identity is rebuilt every render. + }, [ + host, + identity?.displayName, + identity?.profilePictureUrl, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + hasSettings, + ]); + + const setActions = host?.setActions; + + // No deps: a click has to reach the current closure. + useEffect(() => { + setActions?.(actions); + }); + + // Handlers only: clearing the data too would blink the controls mid-switch. + useEffect( + () => () => { + setActions?.({}); + }, + [setActions], + ); +} + +/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */ +export function useSuppressQuickNavRail(active = true): void { + const host = useQuickNavHost(); + const setChromeless = host?.setChromeless; + useEffect(() => { + if (!active) return; + setChromeless?.(true); + return () => setChromeless?.(false); + }, [active, setChromeless]); +} diff --git a/frontend/editor/src/core/contexts/SidebarContext.tsx b/frontend/editor/src/core/contexts/SidebarContext.tsx index ac9ddbb0df..ce0e5184bb 100644 --- a/frontend/editor/src/core/contexts/SidebarContext.tsx +++ b/frontend/editor/src/core/contexts/SidebarContext.tsx @@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) { ); } +/** For components that render outside a SidebarProvider, such as the rail's tooltips. */ +export function useOptionalSidebarContext(): SidebarContextValue | undefined { + return useContext(SidebarContext); +} + export function useSidebarContext(): SidebarContextValue { const context = useContext(SidebarContext); if (context === undefined) { diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 6bc61fa294..070442a966 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -218,8 +218,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setReaderMode = useCallback( (mode: boolean) => { if (mode) { + // Reading is a mode the open document is put into, not a tool run on it. actions.setWorkbench("viewer"); - actions.setSelectedTool("read"); } dispatch({ type: "SET_READER_MODE", payload: mode }); }, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 0d7a571f98..efb6af40d2 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -1,4 +1,11 @@ -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -14,6 +21,7 @@ import { useFileContext } from "@app/contexts/file/fileHooks"; import { useNavigationState, useNavigationActions, + useNavigationGuard, } from "@app/contexts/NavigationContext"; import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -28,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar"; import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; -import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { + getStartupNavigationAction, + getDefaultWorkbenchForFileCount, +} from "@app/utils/homePageNavigation"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { stripBasePath } from "@app/constants/app"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import { + getToolDisabledReason, + getDisabledLabel, +} from "@app/components/tools/fullscreen/shared"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode"; import { FilesPageProvider, useFilesPage, @@ -42,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; import { Button } from "@app/ui/Button"; +import "@app/components/layout/WorkspaceFrame.css"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -90,9 +110,11 @@ export default function HomePage() { handleToolSelect, handleBackToTools, readerMode, + setReaderMode, setLeftPanelView, toolAvailability, customWorkbenchViews, + toolRegistry, } = useToolWorkflow(); const navigate = useNavigate(); @@ -103,6 +125,7 @@ export default function HomePage() { const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); + const otherApp = useOtherAppSwitch(); const location = useLocation(); // Persisted user preference for the FileSidebar collapsed state. Auto- // collapse on /files is layered on top in the transition effect below and @@ -152,8 +175,64 @@ export default function HomePage() { const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); + const { requestNavigation } = useNavigationGuard(); + + // From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes. + const consumedReaderRequest = useRef(false); + useEffect(() => { + if (consumedReaderRequest.current) return; + consumedReaderRequest.current = true; + if (consumeReaderModeRequest()) setReaderMode(true); + }, [setReaderMode]); const { actions } = useNavigationActions(); + const { searchInterfaceActions } = useViewer(); + + // Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts. + const focusSearchAfterRestore = useRef(false); + useEffect(() => { + if (!readerMode) return; + const onKeyDown = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo) return; + if (e.code !== "KeyK" && e.code !== "KeyF") return; + // Same carve-out the search itself makes: a dialog owns the keyboard. + if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]')) + return; + e.preventDefault(); + setReaderMode(false); + if (e.code === "KeyK") { + focusSearchAfterRestore.current = true; + return; + } + // Visibility is state, so it can open before the bar it renders in exists. + searchInterfaceActions.open(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [readerMode, setReaderMode, searchInterfaceActions]); + + useEffect(() => { + if (readerMode || !focusSearchAfterRestore.current) return; + focusSearchAfterRestore.current = false; + requestAnimationFrame(() => + window.dispatchEvent(new Event("superSearch:focus")), + ); + }, [readerMode]); + + // Clean slate: no tool, out of the file library and reading. + const goToDefaultState = useCallback(() => { + handleBackToTools(); + if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME); + actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length)); + }, [ + handleBackToTools, + location.pathname, + navigate, + actions, + activeFiles.length, + ]); + // Sync the /files* URL into the workbench state so the file manager view // takes over the workbench area when the user lands on it. This is the // only state-of-truth for the active workbench, so keep the URL pinned. @@ -194,6 +273,17 @@ export default function HomePage() { prevWorkbenchRef.current = curr; // fileSidebarCollapsed read as snapshot on transition only. }, [navigationState.workbench]); + // Imperative, so the toggle still works while reading. Never persisted: not a preference. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setFileSidebarCollapsed( + readerMode ? true : readPersistedSidebarCollapsed(), + ); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); + const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); @@ -242,6 +332,38 @@ export default function HomePage() { const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); + // The tool picker's own helpers, so the wording can't drift. + const quickNavToolReasons = useMemo(() => { + const reasons: QuickNavToolReasons = {}; + for (const id of ["automate", "sharedSign"] as const) { + const tool = toolRegistry[id]; + if (!tool) continue; + const disabledReason = getToolDisabledReason( + id, + tool, + toolAvailability, + config?.premiumEnabled, + ); + if (!disabledReason) continue; + const { key, fallback } = getDisabledLabel(disabledReason); + reasons[id] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [toolRegistry, toolAvailability, config?.premiumEnabled, t]); + + // Shared with the sidebar's own toggle. On /files it leaves rather than collapses. + const handleSidebarToggle = useCallback(() => { + if (navigationState.workbench === "myFiles") { + navigate(EDITOR_BASENAME); + return; + } + setFileSidebarCollapsed((c) => { + const next = !c; + writePersistedSidebarCollapsed(next); + return next; + }); + }, [navigationState.workbench, navigate]); + const [showSwipeHint, setShowSwipeHint] = useState( () => !readSwipeHintSeen(), ); @@ -395,6 +517,16 @@ export default function HomePage() { return (
+ setConfigModalOpen(true)} + requestNavigation={requestNavigation} + readerMode={readerMode} + onSetReaderMode={setReaderMode} + onGoToDefaultState={goToDefaultState} + onSelectTool={handleToolSelect} + toolReasons={quickNavToolReasons} + /> {isMobile ? (
- - ) : undefined - } - onToggleCollapse={() => { - if (navigationState.workbench === "myFiles") { - navigate(EDITOR_BASENAME); - return; +
+ { - const next = !c; - writePersistedSidebarCollapsed(next); - return next; - }); - }} - onOpenSettings={() => setConfigModalOpen(true)} - /> + toggleIcon={ + navigationState.workbench === "myFiles" ? ( + + ) : undefined + } + active={navigationState.workbench === "myFiles"} + // Forced: a deep link to /files has no transition to collapse on. + collapsed={ + navigationState.workbench === "myFiles" || + fileSidebarCollapsed + } + onToggleCollapse={handleSidebarToggle} + onOpenSettings={() => setConfigModalOpen(true)} + /> +
{!hideToolPanel && } diff --git a/frontend/editor/src/core/routes/hasPortal.ts b/frontend/editor/src/core/routes/hasPortal.ts new file mode 100644 index 0000000000..3d7f107ac3 --- /dev/null +++ b/frontend/editor/src/core/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Whether this build ships the processor. Shadowed per build. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts index df3ce94b23..a2e2c9c7e3 100644 --- a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts @@ -60,7 +60,8 @@ function fixture(filename: string): string { } async function openSamplePdfInViewer(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts index 67df3865e1..f0da928122 100644 --- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts @@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join( ); async function openViewerWithSample(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts index 48765392fe..c1a71ac48c 100644 --- a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -27,6 +27,7 @@ async function restoreEnabled( } const NO_RESTORE = "this build ships the workbench restore off"; +const NO_PORTAL = "this build ships no processor to switch to"; // Switching editor -> processor unmounts every editor provider; the session record // in sessionStorage is what brings the workbench back on return. @@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => { page.getByRole("radio", { name: /Active Files/i }), ).toBeChecked(); - // Out through the sidebar footer switch - the real user path. - await page.getByRole("button", { name: "Open PDF Processor" }).click(); + // Out through the rail's processor mark, the only chrome that offers the switch. + const processorMark = page.getByRole("button", { name: /^Processor$/i }); + test.skip( + !(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)), + NO_PORTAL, + ); + await processorMark.click(); await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); // Split the two halves of the feature: if this fails the writer is at fault, diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 31f9666271..1a74158022 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -46,7 +46,7 @@ html[data-app-theme="light"] { non-text floor applies. Scheme-independent: a filled badge reads white on either ground. */ --c-success-solid: var(--p-green-700); - --c-danger-solid: var(--p-red-600); + --c-danger-solid: var(--p-red-700); --c-warning-solid: var(--p-amber-700); --c-neutral-solid: var(--p-gray-600); --c-accent-solid: var(--p-blue-600); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 554baddcca..50531641cb 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -30,7 +30,12 @@ --radius-nav: 0.625rem; --nav-gutter: 0.5rem; - --nav-rail-w: 3.5rem; + /* Every minimised rail is this wide, so they line up as one column of icons. */ + --nav-rail-w: 3rem; + /* Header row, so the rail's brand and a sidebar's wordmark line up. */ + --nav-header-h: 3rem; + --sidebar-w: 16.25rem; + --sidebar-collapsed-w: var(--nav-rail-w); /* ── Layout sizing ── */ --footer-height: 2rem; diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx index 38947fb91f..37caaa063e 100644 --- a/frontend/editor/src/core/ui/NavSurface.tsx +++ b/frontend/editor/src/core/ui/NavSurface.tsx @@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react"; import "@app/ui/NavSurface.css"; export interface NavSurfaceProps extends HTMLAttributes { - /** Element to render; `section`/`aside` when the box is a landmark. */ - as?: "div" | "section" | "aside"; + /** Element to render; `section`/`aside`/`nav` when the box is a landmark. */ + as?: "div" | "section" | "aside" | "nav"; } /** diff --git a/frontend/editor/src/core/utils/homePageNavigation.ts b/frontend/editor/src/core/utils/homePageNavigation.ts index 001e026710..7a91bfec65 100644 --- a/frontend/editor/src/core/utils/homePageNavigation.ts +++ b/frontend/editor/src/core/utils/homePageNavigation.ts @@ -1,4 +1,4 @@ -import type { WorkbenchType } from "@app/types/workbench"; +import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench"; export type StartupWorkbench = "viewer" | "fileEditor"; @@ -7,6 +7,13 @@ export interface StartupNavigationAction { activeFileIndex?: number; } +/** Several files means the file editor; one or none the viewer. */ +export function getDefaultWorkbenchForFileCount( + fileCount: number, +): WorkbenchType { + return fileCount > 1 ? "fileEditor" : getDefaultWorkbench(); +} + export function getStartupNavigationAction( previousFileCount: number, currentFileCount: number, diff --git a/frontend/editor/src/core/utils/pendingReaderMode.ts b/frontend/editor/src/core/utils/pendingReaderMode.ts new file mode 100644 index 0000000000..faacfba6b8 --- /dev/null +++ b/frontend/editor/src/core/utils/pendingReaderMode.ts @@ -0,0 +1,13 @@ +let pending = false; + +/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */ +export function requestReaderMode(): void { + pending = true; +} + +/** True once per request. */ +export function consumeReaderModeRequest(): boolean { + if (!pending) return false; + pending = false; + return true; +} diff --git a/frontend/editor/src/core/utils/viewTransition.test.ts b/frontend/editor/src/core/utils/viewTransition.test.ts new file mode 100644 index 0000000000..5fb11c8aa2 --- /dev/null +++ b/frontend/editor/src/core/utils/viewTransition.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withViewTransition } from "@app/utils/viewTransition"; + +// The stub carries only the field the helper reads, hence the cast through unknown. +type MutableDoc = { startViewTransition?: unknown }; +const doc = document as unknown as MutableDoc; + +function stubApi(): ReturnType { + const start = vi.fn((cb: () => void) => { + cb(); + return { finished: Promise.resolve() }; + }); + doc.startViewTransition = start; + return start; +} + +function stubReducedMotion(reduced: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduced && query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => { + delete doc.startViewTransition; + vi.unstubAllGlobals(); +}); + +describe("withViewTransition", () => { + it("runs the update inside a transition when one is possible", async () => { + const start = stubApi(); + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("skips the transition when the user asked for less motion", async () => { + // The state change must still happen - only the animation is dropped. + const start = stubApi(); + stubReducedMotion(true); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("still applies the update where the API is unavailable", async () => { + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts index 049c3e7673..85a05ca78d 100644 --- a/frontend/editor/src/core/utils/viewTransition.ts +++ b/frontend/editor/src/core/utils/viewTransition.ts @@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & { startViewTransition?: (cb: () => void) => { finished: Promise }; }; -/** - * Run a state update inside a View Transition so the browser cross-fades - * (and morphs any elements sharing a {@code view-transition-name}) between - * the before/after DOMs. - * - * Falls back to a plain synchronous update when the API is unavailable - * (Firefox <130, JSDOM, motion-reduced preference). - */ +/** Runs a state update in a View Transition, plainly where that is unavailable. */ export function withViewTransition(update: () => void): Promise { if (typeof document === "undefined") { update(); return Promise.resolve(); } + // Callers don't each check: reduced motion still gets the state change. + const reduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const doc = document as ViewTransitionDoc; - if (doc.startViewTransition) { + if (doc.startViewTransition && !reduced) { return doc.startViewTransition(() => flushSync(update)).finished; } update(); diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx deleted file mode 100644 index 21896d9a1d..0000000000 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; - -/** - * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so there's nothing to switch to — - * shadow the brand header back to a plain logo. (Also avoids the desktop - * bundle referencing @portal via the proprietary switcher's imports.) - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/desktop/routes/hasPortal.ts b/frontend/editor/src/desktop/routes/hasPortal.ts new file mode 100644 index 0000000000..0eec40f4c9 --- /dev/null +++ b/frontend/editor/src/desktop/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Desktop inherits proprietary's app but never ships the portal. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index fe8f775fd0..ec6c720297 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; import { Logo } from "@app/ui/Logo"; +import "@app/components/layout/WorkspaceFrame.css"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; import "@portal/components/AppShell.css"; import { NotificationBell } from "@app/components/notifications/NotificationBell"; +import { useIsPhone } from "@app/hooks/useIsMobile"; /** * Compact header shown only under the mobile breakpoint (CSS-hidden on @@ -58,8 +61,10 @@ function MobileTopbar() { * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { - const { mobileNavOpen, closeMobileNav } = useUI(); + const { mobileNavOpen, closeMobileNav, openSettings } = useUI(); const { pathname } = useLocation(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Navigating (tap on a nav row, back button, deep link) always dismisses the // drawer. Depends on pathname only: the close fn's identity changes with any @@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- + {/* portalAccess: being here is proof the processor is available. */} + openSettings()} /> +
+ +
{mobileNavOpen && (
-
- -
+ {/* Phone only: above that the rail carries it, and this would be a second. */} + {isPhone && ( +
+ +
+ )}
{children}
diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index e4918ed859..774dc4d1be 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -11,31 +11,9 @@ import { import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/theme/surface.css"; +import { BrandTile } from "@app/components/shared/BrandTile"; import "@portal/components/EditorStatusCard.css"; -/** The Stirling brand mark, drawn at the hero size. Decorative. */ -function StirlingMark() { - return ( - - - - - - ); -} - /** The instance to headline: the busiest healthy one, else the first. */ function primaryInstance(instances: EditorInstance[]): EditorInstance | null { if (instances.length === 0) return null; @@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) { >
- +
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css index 8e253b6136..847991af20 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.css +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -1,5 +1,4 @@ -/* Unpainted strip at the top of the main column. Height matches the sidebar's - logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */ +/* Unpainted strip at the top of the main column, matching the sidebar header's height. */ .portal-searchbar { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 48b54b177d..43f8c44695 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -1,8 +1,10 @@ .portal-sidebar { - width: 15rem; + width: var(--sidebar-w); height: 100vh; height: 100dvh; /* track mobile browser chrome */ - background: var(--c-bg); + /* Matches the editor's sidebar: one solid panel with a rule on the content side. */ + background: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; flex-shrink: 0; @@ -21,10 +23,6 @@ /* Nav labels stay on one line and are clipped by the narrowing rail so they reveal/hide cleanly as the width animates rather than wrapping. */ -.portal-sidebar__nav, -.portal-sidebar__footer { - overflow-x: hidden; -} .portal-sidebar .sui-navitem__label, .portal-sidebar__section-label { white-space: nowrap; @@ -34,6 +32,8 @@ .portal-sidebar__close { display: none; flex-shrink: 0; + /* Trailing edge: on mobile this is the row's only control. */ + margin-left: auto; } .portal-sidebar__collapse { @@ -78,23 +78,16 @@ /* ---- Collapsed icon rail (desktop only) ---- */ .portal-sidebar[data-collapsed] { - width: var(--nav-rail-w); -} -.portal-sidebar[data-collapsed] .portal-sidebar__logo { - flex-direction: column; - height: auto; - padding: 0.5rem 0; - gap: 0.375rem; -} -.portal-sidebar[data-collapsed] .portal-sidebar__collapse { - margin-left: 0; + width: var(--sidebar-collapsed-w); } +/* Flush, like the nav: the selected row runs the full width of the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__nav { - padding-inline: 0.375rem; + padding-inline: 0; } +/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__section { padding-inline: 0; - align-items: center; + align-items: stretch; } .portal-sidebar[data-collapsed] .portal-sidebar__section-label { display: none; @@ -111,23 +104,16 @@ margin-inline: 0; padding-inline: 0; width: 100%; -} -/* Neutralise the active-item edge-bar geometry (negative margins + overhang) - that assumes the full-width rail. */ -.portal-sidebar[data-collapsed] .sui-navitem.is-active { - width: 100%; - margin-inline: 0; - border-left: none; - border-radius: 0.5rem; - padding-left: 0; + /* A square target, so it takes the rail's radius rather than NavItem's pill. */ + border-radius: var(--radius-md); } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; } -.portal-sidebar__logo { - height: 3.1875rem; /* 51px */ - padding: 0 0.875rem; +.portal-sidebar__header { + height: var(--nav-header-h); + padding: 0 var(--nav-gutter); display: flex; align-items: center; gap: 0.5rem; @@ -136,35 +122,58 @@ .portal-sidebar__nav { flex: 0 1 auto; overflow-y: auto; - padding: 0.75rem 0.625rem; + overflow-x: clip; + /* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */ + padding: var(--nav-gutter) 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--nav-gutter); } .portal-sidebar .sui-navitem { - margin-inline: 0.25rem; - padding-inline: 0.625rem; + margin-inline: 0; + padding-inline: 1.75rem; } -.portal-sidebar .sui-navitem.is-active { - width: calc(100% + 0.75rem); - margin-inline: -0.375rem; +/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */ +.portal-sidebar .sui-navitem.is-active, +.portal-sidebar .sui-navitem.is-active:hover { + background: var(--c-text); + color: var(--c-surface); border-radius: 0; - border-left: 3px solid var(--c-primary); - padding-left: calc(1.25rem - 3px); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover, +[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-mantine-color-scheme="dark"] + .portal-sidebar + .sui-navitem.is-active:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); } .portal-sidebar__section { - padding: 0.5rem 0.375rem 0.375rem; + padding: 0.5rem 0 0.375rem; display: flex; flex-direction: column; gap: 0.375rem; } +/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */ +.portal-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + .portal-sidebar__section-label { margin: 0; - padding: 0 0.5rem; + /* Its own 0.5rem, plus the inset the nav and section no longer add. */ + padding: 0 1.375rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.02em; @@ -181,4 +190,18 @@ only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; + overflow-x: hidden; +} + +/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */ +.workspace-frame .portal-sidebar { + height: 100%; + position: static; +} + +@media (max-width: 48rem) { + .workspace-frame .portal-sidebar { + position: fixed; + height: auto; + } } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 685fa32722..2895ad4ec4 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,20 +1,16 @@ import { useMediaQuery } from "@mantine/hooks"; import { Tooltip } from "@mantine/core"; import { ActionIcon, NavItem, NavSurface } from "@app/ui"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; +import { Logo } from "@app/ui/Logo"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; import { useOpenPlan } from "@portal/hooks/useOpenPlan"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import { EDITOR_BASENAME } from "@app/routes/editorBasename"; -import { takeEditorReturnPath } from "@app/services/workbenchSession"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [ ]; /** Must match the shell breakpoint in AppShell.css / Sidebar.css. */ -const MOBILE_QUERY = "(max-width: 48rem)"; +export const MOBILE_QUERY = "(max-width: 48rem)"; export function Sidebar() { const { activeView, setActiveView } = useView(); const { - openSettings, mobileNavOpen, closeMobileNav, sidebarCollapsed, toggleSidebarCollapsed, } = useUI(); const { t } = useTranslation(); - const navigate = useNavigate(); const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); @@ -54,14 +48,6 @@ export function Sidebar() { // off-canvas drawer, so the icon-rail state never applies there. const collapsed = sidebarCollapsed && !isMobile; - // Editor and portal are one SPA when the editor serves this origin's root, so - // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) - // needs a full page load. - const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); - else window.location.href = EDITOR_URL; - }; - // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -107,25 +93,13 @@ export function Sidebar() { // Off-canvas on mobile: remove from the tab order and accessibility tree. inert={isMobile && !mobileNavOpen} > -
- +
+ {!collapsed && } - - - + } collapsed={collapsed} /> diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index c64935bb16..ca040d4bbe 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import { RootGate } from "@app/routes/RootGate"; // Import global styles @@ -80,40 +82,52 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. Absent from core/desktop builds (empty stub). */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own shell, before the catch-all. An empty stub in core. */} + {getAdminRouteExtensions()} - {/* All other routes need AppProviders for backend integration. - RootGate makes "/" route by role BEFORE any of it mounts, so a user - bound for the processor never boots the editor on the way. */} - - - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* The editor and its tool routes - Landing handles auth logic */} - } /> - - - {WATCHED_FOLDERS_ENABLED && } - - - - } - /> + {/* All other routes need AppProviders for backend integration. RootGate + routes "/" by role before any of it mounts. */} + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + {/* Self-hosted has no signup: old links land on login. */} + } + /> + } + /> + } + /> + } + /> + + {/* The editor and its tool routes - Landing handles auth logic */} + } /> + + + {WATCHED_FOLDERS_ENABLED && } + + + + } + /> + ); diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx deleted file mode 100644 index 9ba0b6438d..0000000000 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; - -/** - * Sidebar brand header for builds that ship the processor. When this user can - * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark - * morphs into a chevron and opens the switch menu (the same BrandSwitcher the - * processor sidebar uses). Users without access get a plain logo. - * - * The access gate lives in {@link useOtherAppSwitch} so this header and the - * sidebar footer's "Open PDF Processor" row are driven by one answer. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const otherApp = useOtherAppSwitch(); - - if (!otherApp) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/data/processorEntitySearch.ts b/frontend/editor/src/proprietary/data/processorEntitySearch.ts index 544babf59f..58b32b64d3 100644 --- a/frontend/editor/src/proprietary/data/processorEntitySearch.ts +++ b/frontend/editor/src/proprietary/data/processorEntitySearch.ts @@ -6,15 +6,10 @@ import type { PortalEntityItems, PortalEntityScopeId, } from "@portal/search/entitySearch"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; type EntitySearchModule = typeof import("@portal/search/entitySearch"); -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// fetch or offer entities that have nowhere to open. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - const NO_GROUPS: SuperSearchGroup[] = []; const NO_SCOPES: readonly PortalEntityScopeId[] = []; @@ -41,7 +36,8 @@ export function useProcessorEntityGroups( ): SuperSearchGroup[] { const [mod, setMod] = useState(null); const modRef = useRef(null); - const active = enabled && includePortal; + // Without the portal these entities have nowhere to open, so don't fetch them. + const active = enabled && HAS_PORTAL; const hasQuery = trimmed.length > 0; useEffect(() => { diff --git a/frontend/editor/src/proprietary/data/processorSearchIndex.ts b/frontend/editor/src/proprietary/data/processorSearchIndex.ts index ae48893e25..8d99b2f77a 100644 --- a/frontend/editor/src/proprietary/data/processorSearchIndex.ts +++ b/frontend/editor/src/proprietary/data/processorSearchIndex.ts @@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // the lazy portal chunk into the main bundle the way @portal/* values would. import { usersCapabilities } from "@app/portal/usersCapabilities"; import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; export type { ProcessorSearchEntry }; -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// offer destinations that would 404 elsewhere. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - /** * The portal's in-app views. Deliberately a static mirror of the portal's nav * (labels via the same portal.nav.* keys its sidebar uses) rather than an @@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [ }, ]; -export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal +// Empty without the portal: these destinations would 404. +export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL ? VIEWS : []; diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index 62c9083aad..7406078d30 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; import HomePage from "@app/pages/HomePage"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; @@ -27,6 +28,9 @@ export default function Landing() { const loading = authLoading || configLoading || backendProbe.loading; + // The backend-down screen is not the app. Loading is: it resolves in a moment. + useSuppressQuickNavRail(!session && backendProbe.status !== "up"); + // Debug: Track Landing component lifecycle useEffect(() => { const mountId = Math.random().toString(36).substring(7); diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 22ce46f03c..a422376086 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -2,11 +2,9 @@ import { lazy } from "react"; import type { ReactElement } from "react"; import { Route } from "react-router-dom"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - -const PortalApp = includePortal +const PortalApp = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/PortalApp"); return { default: m.PortalApp }; @@ -16,7 +14,7 @@ const PortalApp = includePortal /** * Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment. */ -const ConnectCallback = includePortal +const ConnectCallback = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/views/ConnectCallback"); return { default: m.default }; diff --git a/frontend/editor/src/proprietary/routes/hasPortal.ts b/frontend/editor/src/proprietary/routes/hasPortal.ts new file mode 100644 index 0000000000..e3c54bc50e --- /dev/null +++ b/frontend/editor/src/proprietary/routes/hasPortal.ts @@ -0,0 +1,3 @@ +/** Dev always ships it, so the switch is there to work on. */ +export const HAS_PORTAL = + import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 2e091ebf05..07a563ea5f 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent"; import ConnectApprove from "@app/routes/ConnectApprove"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; @@ -96,46 +98,63 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own top-level shell, before the catch-all. */} + {getAdminRouteExtensions()} - {/* Everything else needs the auth/backend providers. RootGate makes "/" - route by role BEFORE any of it mounts, so a user bound for the - processor never boots the editor on the way. */} - - - - - - - } /> - } /> - } /> - } /> - } /> - {/* Human half of the self-hosted account-link handshake. It - lives on this origin because a customer hostname can - never be in the provider's redirect allow-list. */} - } /> - {/* Shared-file links. Team invites are NOT routed here: on - SaaS they are accepted in-app via the Supabase team - invitation banner, not the Spring password-based - /invite/:token page used by the self-hosted build. */} - } /> - } /> - - - - - - } - /> + {/* Everything else needs the auth/backend providers. RootGate routes "/" + by role before any of it mounts. */} + + + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + } /> + } + /> + } /> + } + /> + {/* Human half of the self-hosted account-link handshake. + It lives on this origin because a customer hostname can + never be in the provider's redirect allow-list. Grouped + with the pages above: it is an approval step, not the + app. */} + } /> + {/* Shared-file links. Team invites are NOT routed here: + on SaaS they are accepted in-app via the Supabase team + invitation banner, not the Spring password-based + /invite/:token page used by the self-hosted build. */} + } + /> + + } /> + + + + + + } + /> + ); From a48356a2d22babeae7ad7c66673bee9a405fa083 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:17:21 +0100 Subject: [PATCH 15/17] Deploy a dev SaaS server alongside the PR previews and main demo (#7697) --- .github/workflows/Saas-Dev-Deploy.yml | 246 ++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 .github/workflows/Saas-Dev-Deploy.yml diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml new file mode 100644 index 0000000000..733b92bca2 --- /dev/null +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -0,0 +1,246 @@ +name: Auto SaaS Dev Deployment + +on: + push: + branches: + - saas-prod + workflow_dispatch: + +permissions: + contents: read + +env: + FRONTEND_PORT: "901" + BACKEND_PORT: "902" + DEPLOY_DIR: /stirling/SAAS-DEV + +jobs: + deploy-saas-dev: + runs-on: ubuntu-latest + environment: saas-dev + concurrency: + group: saas-dev-deploy + cancel-in-progress: true + permissions: + contents: read + packages: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Check SaaS configuration + id: config + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + run: | + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT + + - name: Get commit hash + id: commit-hash + run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push backend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-backend + cache-to: type=gha,mode=max,scope=stirling-saas-backend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + platforms: linux/amd64 + + - name: Build and push frontend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-frontend + cache-to: type=gha,mode=max,scope=stirling-saas-frontend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + VITE_BUILD_MODE=development + VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + platforms: linux/amd64 + + - name: Build and push AI engine image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-engine + cache-to: type=gha,mode=max,scope=stirling-saas-engine + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest + platforms: linux/amd64 + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh/ + echo "$SSH_KEY" > ../private.key + sudo chmod 600 ../private.key + + - name: Deploy to VPS + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ github.token }} + VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }} + SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }} + SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }} + SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }} + PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }} + STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }} + KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }} + KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }} + KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }} + run: | + set -euo pipefail + + BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}" + + yaml() { + printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')" + } + + ENGINE_SECRET="$(openssl rand -hex 32)" + AI_BACKEND_VARS=" + SYSTEM_AIENGINE_ENABLED: \"true\" + SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\" + APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\" + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")" + AI_SERVICE=" + + saas-engine: + container_name: stirling-saas-dev-engine + image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG} + environment: + ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY") + VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY") + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET") + restart: on-failure:5" + + cat > docker-compose.yml << EOF + version: '3.3' + services: + saas-backend: + container_name: stirling-saas-dev-backend + image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG} + ports: + - "${BACKEND_PORT}:8080" + volumes: + - ${DEPLOY_DIR}/config:/configs:rw + - ${DEPLOY_DIR}/logs:/logs:rw + - ${DEPLOY_DIR}/storage:/storage:rw + environment: + SPRING_PROFILES_ACTIVE: "saas" + DISABLE_ADDITIONAL_FEATURES: "false" + SAAS_DB_URL: $(yaml "$SAAS_DB_URL") + SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME") + SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD") + SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF") + SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET") + PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT") + STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED") + KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID") + KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN") + KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID") + SYSTEM_DEFAULTLOCALE: en-US + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "${BASE_URL}" + baseUrl: "${BASE_URL}"${AI_BACKEND_VARS} + restart: on-failure:5 + + saas-frontend: + container_name: stirling-saas-dev-frontend + image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG} + ports: + - "${FRONTEND_PORT}:80" + environment: + VITE_API_BASE_URL: "http://saas-backend:8080" + depends_on: + - saas-backend + restart: on-failure:5${AI_SERVICE} + EOF + + SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) + + scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml" + + ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH + set -e + mkdir -p ${DEPLOY_DIR}/{config,logs,storage} + mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml + chmod 600 ${DEPLOY_DIR}/docker-compose.yml + cd ${DEPLOY_DIR} + printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin + docker-compose down --remove-orphans 2>/dev/null || true + docker-compose pull + docker-compose up -d + docker logout ghcr.io >/dev/null 2>&1 || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + ENDSSH + + - name: Wait for the backend to answer + env: + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + run: | + URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status" + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true) + if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi + sleep 10 + done + echo "::error::SaaS dev backend did not become healthy within 10 minutes" + exit 1 + + - name: Cleanup temporary files + if: always() + run: rm -f ../private.key docker-compose.yml + continue-on-error: true From 849d616451feb79b706ad6f9a9250924327b0020 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Aug 2026 23:45:10 +0100 Subject: [PATCH 16/17] Fix refreshing causing you to go to the Processor (#7694) --- .../common/util/RequestUriUtilsTest.java | 10 ++++++++ .../configuration/SecurityConfiguration.java | 6 ++--- .../src/proprietary/routes/Login.test.tsx | 23 ++++++++++++++++++- .../editor/src/proprietary/routes/Login.tsx | 8 +++++-- .../proprietary/services/apiClientSetup.ts | 4 ++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 72e5eae9a2..c144f9726e 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -113,6 +113,16 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_editorRouteOwnedByFrontend() { + // /editor (and its tool routes) is an SPA route: a direct-nav/refresh must + // serve index.html, not the auth filter's 302-to-/login. Regression test for + // the editor moving from / to /editor, whose refresh bounced processor users + // to the processor because the redirect dropped the return path. + assertTrue(RequestUriUtils.isFrontendRoute("", "/editor")); + assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor")); + } + @Test void testIsFrontendRoute_filesRouteOwnedByFrontend() { // /files and /files/ are FileManagerView routes - they 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 9fc4428f73..0f0c7315d9 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 @@ -357,12 +357,12 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static - // resource return RequestUriUtils.isStaticResource( contextPath, uri) || RequestUriUtils.isPublicAuthEndpoint( - uri, contextPath); + uri, contextPath) + || RequestUriUtils.isFrontendRoute( + contextPath, uri); }) .permitAll() .anyRequest() diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 29ae9d9d79..5553f202fb 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -234,7 +234,10 @@ describe("Login", () => { ); }; - afterEach(() => window.history.replaceState({}, "", "/")); + afterEach(() => { + window.history.replaceState({}, "", "/"); + sessionStorage.clear(); + }); it("returns to where the user came from", async () => { signedIn(); @@ -247,6 +250,24 @@ describe("Login", () => { }); }); + // A full-page 401 redirect drops router state and Spring can strip ?from=, + // leaving the return path only in the sessionStorage stash. Without reading + // it here a processor user refreshing /editor falls through to the role + // router and lands on the processor. + it("returns to the stashed path when there is no ?from=", async () => { + signedIn(); + sessionStorage.setItem("stirling_post_login_path", "/compress"); + renderAtLogin(""); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/compress", { + replace: true, + }); + }); + // Consumed, so a later sign-in can't reuse a stale path. + expect(sessionStorage.getItem("stirling_post_login_path")).toBeNull(); + }); + // Delegated to the shared isSafePostLoginRedirect, so the backslash form // (browsers normalise "\" to "/") and auth routes are covered too. it.each([ diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index b134d9ee89..7fd3e86670 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -7,7 +7,10 @@ import { } from "react-router-dom"; import { Button } from "@app/ui/Button"; import { isSafePostLoginRedirect } from "@app/auth"; -import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; +import { + setPostLoginRedirectPath, + consumePostLoginRedirectPath, +} from "@app/auth/spring/springAuthClient"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; @@ -207,7 +210,8 @@ export default function Login() { useEffect(() => { if (loading) return; if (!session) return; - const returnPath = resolveReturnPath(); + const stashed = consumePostLoginRedirectPath(); + const returnPath = resolveReturnPath() ?? stashed; if (returnPath) { navigate(returnPath, { replace: true }); return; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 8615156e9a..3f50e1bb4c 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,6 +1,7 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; import { withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; let isRefreshing = false; let failedQueue: Array<{ @@ -93,6 +94,9 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + setPostLoginRedirectPath( + window.location.pathname + window.location.search, + ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; } From 0a3f0c181474e1b0ebd03744330481aee6f495be Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 28 Aug 2026 10:03:52 +0000 Subject: [PATCH 17/17] Fix redirect bugs in SaaS (#7721) # Description of Changes Fixes various bugs that affected SaaS (and some self-hosted): - Refreshing on Editor caused the user to be redirected to Processor - User was unable to access Processor in SaaS - Deep link hijacking fixes - Fix double prefix `/app/app` issue - Fix going from tool -> editor -> processor -> editor putting you back into tool --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../shared/quickNav/QuickNavRailHost.tsx | 4 +- .../src/core/contexts/ToolWorkflowContext.tsx | 16 +++ .../editor/src/core/hooks/useUrlSync.test.tsx | 80 +++++++++++ frontend/editor/src/core/hooks/useUrlSync.ts | 30 +++- .../httpErrorHandler.basePath.test.ts | 76 +++++++++++ .../src/core/services/httpErrorHandler.ts | 28 ++-- .../core/services/postLoginRedirect.test.ts | 39 ++++++ .../src/core/services/postLoginRedirect.ts | 15 ++ .../core/services/workbenchSession.test.ts | 8 +- .../src/core/services/workbenchSession.ts | 5 +- .../auth/spring/springAuthClient.ts | 22 +-- .../proprietary/auth/supabase/UseSession.tsx | 14 +- .../auth/supabase/portalAccessFetch.test.tsx | 128 ++++++++++++++++++ .../hooks/useOtherAppSwitch.test.tsx | 5 +- .../proprietary/hooks/useOtherAppSwitch.ts | 5 +- .../proprietary/services/apiClientSetup.ts | 6 +- .../services/postLoginRedirect.test.ts | 25 ++++ .../proprietary/services/postLoginRedirect.ts | 7 + .../src/saas/hooks/useOtherAppSwitch.ts | 5 +- .../editor/src/saas/routes/AuthCallback.tsx | 21 ++- frontend/editor/src/saas/routes/Login.tsx | 13 +- .../src/saas/services/apiClient.test.ts | 51 ++++++- .../editor/src/saas/services/apiClient.ts | 25 +++- 23 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useUrlSync.test.tsx create mode 100644 frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts create mode 100644 frontend/editor/src/core/services/postLoginRedirect.test.ts create mode 100644 frontend/editor/src/core/services/postLoginRedirect.ts create mode 100644 frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.test.ts create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.ts diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 5c748b36cc..3fba4d7e1f 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -20,7 +20,7 @@ const SIZE = "1.125rem"; export function QuickNavRailHost() { const { t } = useTranslation(); const navigate = useNavigate(); - const { pathname, search } = useLocation(); + const { pathname } = useLocation(); const host = useQuickNavHost(); const appMounted = Boolean(host?.appMounted); @@ -74,7 +74,7 @@ export function QuickNavRailHost() { returnHome(); return; } - saveEditorReturnPath(pathname + search); + saveEditorReturnPath(); go(PORTAL_BASENAME); }, }, diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 070442a966..b4ef874184 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -29,6 +29,8 @@ import { isBaseWorkbench, } from "@app/types/workbench"; import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; +import { stripBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { filterToolRegistryByQuery } from "@app/utils/toolSearch"; import { useToolHistory } from "@app/hooks/tools/useUserToolActivity"; import { @@ -373,15 +375,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // This runs once to navigate to the user's preferred tab (read/automate) // instead of always starting on the tools tab. const hasAppliedStartupView = React.useRef(false); + // Set when the startup view picks the tool, so the URL sync knows this + // selection came from a preference and must not be written to the address. + const startupSelectedToolRef = React.useRef(null); useEffect(() => { if (hasAppliedStartupView.current) return; + // The URL wins: the startup view decides what you see when you arrive at the + // editor's home, never what a deep link to a tool shows. Without this, a + // "Reader" preference rewrote every / link to /read. + const path = stripBasePath(window.location.pathname); + if (path !== "/" && path !== EDITOR_BASENAME) { + hasAppliedStartupView.current = true; + return; + } const startupView = preferences.defaultStartupView; if (startupView === "read") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "read"; setReaderMode(true); actions.setSelectedTool("read"); } else if (startupView === "automate") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "automate"; actions.setSelectedTool("automate"); setLeftPanelView("toolContent"); } @@ -573,6 +588,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { handleBackToTools, allTools, true, + startupSelectedToolRef, ); // Ref-backed wrappers so callback identities stay stable across renders. diff --git a/frontend/editor/src/core/hooks/useUrlSync.test.tsx b/frontend/editor/src/core/hooks/useUrlSync.test.tsx new file mode 100644 index 0000000000..e35c7dbd44 --- /dev/null +++ b/frontend/editor/src/core/hooks/useUrlSync.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useRef } from "react"; +import type { ToolId } from "@app/types/toolId"; + +const h = vi.hoisted(() => ({ + updateToolRoute: vi.fn(), + clearToolRoute: vi.fn(), +})); + +vi.mock("@app/utils/urlRouting", () => ({ + parseToolRoute: () => ({ workbench: "fileEditor", toolId: null }), + updateToolRoute: h.updateToolRoute, + clearToolRoute: h.clearToolRoute, +})); +vi.mock("@app/utils/scarfTracking", () => ({ firePixel: vi.fn() })); +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { premiumEnabled: true } }), +})); + +import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; + +const registry = { + read: { name: "Read", workbench: "viewer" }, + compress: { name: "Compress", workbench: "fileEditor" }, +} as never; + +/** Drives the hook the way ToolWorkflowContext does, with a startup marker. */ +function useHarness(selectedTool: ToolId | null, startupTool: ToolId | null) { + const ref = useRef(startupTool); + useNavigationUrlSync(selectedTool, vi.fn(), vi.fn(), registry, true, ref); + return ref; +} + +describe("useNavigationUrlSync — startup-view selections", () => { + beforeEach(() => h.updateToolRoute.mockClear()); + + // The default-startup-view preference selects a tool to change the *view*. + // Writing it to the address turned every visit to /editor into /read. + it("never writes the URL for the startup-applied tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + // The effect re-runs whenever the registry identity changes, so a marker that + // was consumed on first sight let the second run write /read anyway. + it("survives a re-run for the same tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + it("still writes the URL when the user picks a different tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "compress" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("compress", registry, false); + }); + + it("writes the URL for a tool chosen without a startup marker", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, null), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("read", registry, false); + }); +}); diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts index 5fad71ba29..76fd67178a 100644 --- a/frontend/editor/src/core/hooks/useUrlSync.ts +++ b/frontend/editor/src/core/hooks/useUrlSync.ts @@ -2,7 +2,7 @@ * URL synchronization hooks for tool routing with registry support */ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef, type MutableRefObject } from "react"; import { ToolId } from "@app/types/toolId"; import { parseToolRoute, @@ -24,6 +24,11 @@ export function useNavigationUrlSync( clearToolSelection: () => void, registry: ToolRegistry, enableSync: boolean = true, + /** + * Tool the default-startup-view preference selected, if any. That selection + * sets the view, not the address, so it must not be written to the URL. + */ + startupSelectedToolRef?: MutableRefObject, ) { const { config } = useAppConfig(); const premiumEnabled = config?.premiumEnabled; @@ -77,8 +82,16 @@ export function useNavigationUrlSync( useEffect(() => { if (!enableSync) return; + const startupTool = startupSelectedToolRef?.current ?? null; + if (selectedTool) { - updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + // A startup-view selection is a view preference, not a navigation: writing + // it here rewrote /editor to /read on every load. The effect re-runs + // whenever the registry identity changes, so the marker has to survive + // until the selection actually moves off it (cleared below). + if (startupTool !== selectedTool) { + updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + } } else if (prevSelectedTool.current !== null) { // Only clear URL if we had a tool before (user navigated away) // Don't clear on initial load when both current and previous are null @@ -88,8 +101,19 @@ export function useNavigationUrlSync( } } + // Spent once the user leaves the startup-applied tool, so re-picking it + // later is a real navigation and does update the URL. + if ( + startupSelectedToolRef && + startupTool !== null && + prevSelectedTool.current === startupTool && + selectedTool !== startupTool + ) { + startupSelectedToolRef.current = null; + } + prevSelectedTool.current = selectedTool; - }, [selectedTool, registry, enableSync]); + }, [selectedTool, registry, enableSync, startupSelectedToolRef]); // Handle browser back/forward navigation useEffect(() => { diff --git a/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts new file mode 100644 index 0000000000..27a4231a2c --- /dev/null +++ b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("@app/services/specialErrorToasts", () => ({ + showSpecialErrorToast: vi.fn(() => false), +})); +vi.mock("@app/services/saasErrorInterceptor", () => ({ + handleSaaSError: vi.fn(() => false), +})); +vi.mock("@app/services/errorUtils", () => ({ + broadcastErroredFiles: vi.fn(), + extractErrorFileIds: vi.fn(() => []), + normalizeAxiosErrorData: vi.fn(async (d: unknown) => d), +})); + +const hrefs: string[] = []; + +/** Serve the app from `base`, sitting on `pathname`, then load the handler fresh. */ +async function loadAt(base: string, pathname: string) { + document.head.innerHTML = ``; + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname, + search: "", + origin: "http://localhost:3000", + get href() { + // Absolute: jsdom resolves against this. + return "http://localhost:3000" + pathname; + }, + set href(v: string) { + hrefs.push(v); + }, + }, + }); + vi.resetModules(); + return (await import("@app/services/httpErrorHandler")).handleHttpError; +} + +const unauthorized = { + isAxiosError: true, + message: "unauthorized", + config: {}, + response: { status: 401, data: {} }, +}; + +beforeEach(() => { + hrefs.length = 0; + sessionStorage.clear(); + localStorage.clear(); +}); +afterEach(() => vi.resetModules()); + +describe("401 return path is router-relative", () => { + // Login replays this through navigate(), which re-applies the router + // basename. Carrying /app here produced /app/app/compress. + it("strips the base path on a subpath deploy", async () => { + const handle = await loadAt("/app/", "/app/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/app/login?from=%2Fcompress"); + }); + + it("is unchanged at the origin root", async () => { + const handle = await loadAt("/", "/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/login?from=%2Fcompress"); + }); +}); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index dd0c692d97..30272c381e 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -12,7 +12,8 @@ import { clampText, extractAxiosErrorMessage, } from "@app/services/httpErrorUtils"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Module-scoped state to reduce global variable usage const recentSpecialByEndpoint: Record = {}; @@ -21,26 +22,9 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate // Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it. const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path"; -function isSafePostLoginPath(path: string): boolean { - if ( - !path.startsWith("/") || - path.startsWith("//") || - path.startsWith("/\\") - ) { - return false; - } - const lowered = path.toLowerCase(); - return ( - !lowered.startsWith("/login") && - !lowered.startsWith("/auth/") && - !lowered.startsWith("/oauth2") && - !lowered.startsWith("/saml2") - ); -} - function stashPostLoginRedirect(path: string): void { try { - if (typeof window === "undefined" || !isSafePostLoginPath(path)) return; + if (typeof window === "undefined" || !isSafePostLoginRedirect(path)) return; window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path); } catch { // sessionStorage unavailable (private mode) — fail open @@ -128,7 +112,11 @@ export async function handleHttpError(error: unknown): Promise { console.debug("[httpErrorHandler] 401 detected, redirecting to login"); // Spring 302-strips the ?from= query from /login, so stash the return // path in sessionStorage (AuthCallback reads it after SSO round-trip). - const currentLocation = window.location.pathname + window.location.search; + // Router-relative, not browser-relative: every consumer replays this + // through navigate(), which re-applies the basename. Keeping the base + // path here yields /app/app/ on a subpath deploy. + const currentLocation = + stripBasePath(window.location.pathname) + window.location.search; stashPostLoginRedirect(currentLocation); let hadStoredJwt = false; try { diff --git a/frontend/editor/src/core/services/postLoginRedirect.test.ts b/frontend/editor/src/core/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..9f1687f392 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Core default. Rejects off-origin forms and the auth routes every build has +// (/login, /auth/…); Spring SSO routes are the proprietary override's concern. +describe("isSafePostLoginRedirect (core base)", () => { + it("accepts same-origin router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/compress")).toBe(true); + expect(isSafePostLoginRedirect("/editor?foo=bar")).toBe(true); + expect(isSafePostLoginRedirect("/oauth/consent?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("rejects empty and non-string values", () => { + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect(undefined)).toBe(false); + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(42 as unknown)).toBe(false); + }); + + it("rejects off-origin and protocol-relative forms", () => { + expect(isSafePostLoginRedirect("//evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("https://evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("editor")).toBe(false); + }); + + it("rejects the universal auth routes so returning back can never loop", () => { + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/login?next=%2Feditor")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("leaves the Spring SSO routes to the proprietary override", () => { + expect(isSafePostLoginRedirect("/oauth2/authorize")).toBe(true); + expect(isSafePostLoginRedirect("/saml2/login")).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/services/postLoginRedirect.ts b/frontend/editor/src/core/services/postLoginRedirect.ts new file mode 100644 index 0000000000..d9ffe82940 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.ts @@ -0,0 +1,15 @@ +/** + * Is `path` safe to send a user back to after they log in? + */ +export function isSafePostLoginRedirect(path: unknown): path is string { + if (typeof path !== "string" || path.length === 0) return false; + if ( + !path.startsWith("/") || + path.startsWith("//") || + path.startsWith("/\\") + ) { + return false; + } + const lowered = path.toLowerCase(); + return !lowered.startsWith("/login") && !lowered.startsWith("/auth/"); +} diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts index d9feec8d1c..0258b83623 100644 --- a/frontend/editor/src/core/services/workbenchSession.test.ts +++ b/frontend/editor/src/core/services/workbenchSession.test.ts @@ -55,10 +55,14 @@ describe("workbench session record", () => { }); describe("editor return path", () => { - it("is consumed by the first take", () => { - saveEditorReturnPath("/compress?x=1"); + it("captures the live address bar and is consumed by the first take", () => { + // The editor writes its tool route via raw history.pushState, so the save + // must read window.location, not a lagging router location. + window.history.pushState({}, "", "/compress?x=1"); + saveEditorReturnPath(); expect(takeEditorReturnPath()).toBe("/compress?x=1"); expect(takeEditorReturnPath()).toBeNull(); + window.history.pushState({}, "", "/"); }); }); diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts index 67f6d1abfc..2f23db8655 100644 --- a/frontend/editor/src/core/services/workbenchSession.ts +++ b/frontend/editor/src/core/services/workbenchSession.ts @@ -2,6 +2,7 @@ // does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber. import type { StirlingFileStub } from "@app/types/fileContext"; +import { stripBasePath } from "@app/constants/app"; const SESSION_KEY = "stirling.workbench.session"; /** Bumped when the record's shape or meaning changes, so an old one is discarded rather than @@ -153,8 +154,10 @@ export function isSeedableView( return view !== undefined && SEEDABLE_VIEWS.includes(view); } -export function saveEditorReturnPath(path: string): void { +export function saveEditorReturnPath(): void { try { + const path = + stripBasePath(window.location.pathname) + window.location.search; sessionStorage.setItem(RETURN_PATH_KEY, path); } catch { // Best-effort: the switch back just lands on the editor root. diff --git a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts index b9583e3dee..6d887ce8a9 100644 --- a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts +++ b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts @@ -16,6 +16,7 @@ import { AxiosError, type AxiosRequestConfig } from "axios"; import { getSpringAuthConfig } from "@app/auth/config"; import { type OAuthProvider } from "@app/auth/spring/oauthTypes"; import { resetOAuthState } from "@app/auth/spring/oauthStorage"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import type { AuthUser as User, AuthSession as Session, @@ -100,23 +101,10 @@ function persistRedirectPath(path: string): void { } } -// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative -// URLs to guard against open-redirect abuse if the stored value is tampered with. -export function isSafePostLoginRedirect(path: unknown): path is string { - if (typeof path !== "string" || path.length === 0) return false; - if (!path.startsWith("/") || path.startsWith("//")) return false; - if (path.startsWith("/\\")) return false; - const lowered = path.toLowerCase(); - if ( - lowered.startsWith("/login") || - lowered.startsWith("/auth/") || - lowered.startsWith("/oauth2") || - lowered.startsWith("/saml2") - ) { - return false; - } - return true; -} +// The safe-return-path rule lives in the shared @app/services/postLoginRedirect +// extension point (proprietary override adds the Spring SSO routes). Re-exported +// here so existing importers via @app/auth keep resolving it. +export { isSafePostLoginRedirect }; export function setPostLoginRedirectPath( path: string | null | undefined, diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx index 385f3f4e35..07670bfff5 100644 --- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx @@ -16,6 +16,7 @@ import type { import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; import { AuthContext } from "@app/auth/context"; import { isAdminRole } from "@app/auth/roles"; +import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { defaultTranslate, type AuthContextValue, @@ -154,14 +155,23 @@ export function SupabaseAuthProvider({ return; } let cancelled = false; + // Same API base the rest of the app uses: SaaS serves the frontend and the + // API from different hosts, so a root-relative path never reaches /me. + const apiBase = (getApiBaseUrl() || "").replace(/\/+$/, ""); + const meUrl = `${apiBase}/api/v1/auth/me`; const loadAccess = () => { - void fetch("/api/v1/auth/me", { + void fetch(meUrl, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, }) - .then((res) => (res.ok ? res.json() : null)) + .then((res) => { + // Must throw, not resolve null: swallowing a non-ok leaves + // portalAccess undefined and hangs the portal gate on a spinner. + if (!res.ok) throw new Error(`auth/me responded ${res.status}`); + return res.json(); + }) .then( ( data: { diff --git a/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx new file mode 100644 index 0000000000..3b3d9b37c8 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { useContext } from "react"; + +const h = vi.hoisted(() => ({ apiBase: "/" })); + +vi.mock("@app/services/apiClientConfig", () => ({ + getApiBaseUrl: () => h.apiBase, +})); + +const sbSession = { + access_token: "supabase-token", + user: { + id: "u1", + email: "user@example.com", + is_anonymous: false, + app_metadata: {}, + user_metadata: {}, + }, +}; + +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ + auth: { + getSession: () => Promise.resolve({ data: { session: sbSession } }), + onAuthStateChange: () => ({ + data: { subscription: { unsubscribe: () => {} } }, + }), + refreshSession: () => Promise.resolve({ data: {}, error: null }), + signOut: () => Promise.resolve({ error: null }), + }, + }), +})); + +import { SupabaseAuthProvider } from "@app/auth/supabase/UseSession"; +import { AuthContext } from "@app/auth/context"; + +function Probe() { + const v = useContext(AuthContext); + return ( + <> + {String(v?.portalAccess)} + {/* Raw, un-defaulted value: this is what SaasPortalGate reads to decide + "access not known yet" vs "denied". undefined = spinner forever. */} + {String(v?.user?.portalAccess)} + + ); +} + +const mount = () => + render( + + + , + ); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("supabase provider portalAccess lookup", () => { + // SaaS serves the frontend and the API from different hosts; a root-relative + // path silently missed /me, so a granted non-admin was denied the Processor. + it("calls /me on the configured API base, not the page origin", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("keeps a same-origin base as a single leading slash", async () => { + h.apiBase = "/"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("grants access when /me says so", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("access").textContent).toBe("true")); + }); + + // A non-ok used to resolve to null and return early, leaving the raw + // portalAccess undefined forever - SaasPortalGate reads that as "still + // loading" and hangs on a spinner instead of falling back. + it("resolves the raw portalAccess when /me returns non-ok", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); + + it("leaves the raw portalAccess defined when the request rejects", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockRejectedValue(new Error("network down")); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx index c2a81c53d9..4acdc7a17a 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx @@ -7,9 +7,10 @@ const mocks = vi.hoisted(() => ({ portalAccess: true, })); +// The hook reads window.location for the return path (not useLocation), because +// the editor's raw history.pushState leaves react-router's location stale. vi.mock("react-router-dom", () => ({ useNavigate: () => mocks.navigate, - useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }), })); vi.mock("@app/auth/context", () => ({ useAuth: () => ({ portalAccess: mocks.portalAccess }), @@ -27,6 +28,7 @@ beforeEach(() => { sessionStorage.clear(); vi.clearAllMocks(); mocks.portalAccess = true; + window.history.pushState({}, "", "/"); }); describe("useOtherAppSwitch", () => { @@ -45,6 +47,7 @@ describe("useOtherAppSwitch", () => { }); it("records where to return to, then navigates to the processor", () => { + window.history.pushState({}, "", "/compress?mode=fast"); const { result } = renderHook(() => useOtherAppSwitch()); result.current?.onOpen(); mocks.requestNavigation.mock.calls[0][0](); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts index dff36a06f4..69b55aff35 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/context"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; @@ -12,7 +12,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const { portalAccess } = useAuth(); const navigate = useNavigate(); - const location = useLocation(); const { actions } = useNavigationActions(); if (!portalAccess) return null; return { @@ -20,7 +19,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null { onOpen: () => // Through the guard, so unsaved edits get the same warning as any other navigation. actions.requestNavigation(() => { - saveEditorReturnPath(location.pathname + location.search); + saveEditorReturnPath(); navigate(PORTAL_BASENAME); }), }; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 3f50e1bb4c..459f527080 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,5 +1,5 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; @@ -94,8 +94,10 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + // Router-relative: Login replays this through navigate(), which applies + // the basename itself. See the same note in httpErrorHandler. setPostLoginRedirectPath( - window.location.pathname + window.location.search, + stripBasePath(window.location.pathname) + window.location.search, ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..6f2451f336 --- /dev/null +++ b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Proprietary override: the core base plus the Spring SSO routes (/oauth2, /saml2). +describe("isSafePostLoginRedirect (proprietary override)", () => { + it("still accepts ordinary router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/share/abc123?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("inherits the base rejections", () => { + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect("//evil.example")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil")).toBe(false); + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("also rejects the Spring SSO routes", () => { + expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(false); + expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.ts new file mode 100644 index 0000000000..5cd6546fa7 --- /dev/null +++ b/frontend/editor/src/proprietary/services/postLoginRedirect.ts @@ -0,0 +1,7 @@ +import { isSafePostLoginRedirect as isSafeBaseRedirect } from "@core/services/postLoginRedirect"; + +export function isSafePostLoginRedirect(path: unknown): path is string { + if (!isSafeBaseRedirect(path)) return false; + const lowered = path.toLowerCase(); + return !lowered.startsWith("/oauth2") && !lowered.startsWith("/saml2"); +} diff --git a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts index 67d0aa7d49..e97d4d0ed3 100644 --- a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { usePortalAccess } from "@app/hooks/usePortalAccess"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; @@ -13,7 +13,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const portalAccess = usePortalAccess(); const navigate = useNavigate(); - const location = useLocation(); const { actions } = useNavigationActions(); if (!portalAccess) return null; return { @@ -21,7 +20,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null { onOpen: () => // Through the guard, so unsaved edits get the same warning as any other navigation. actions.requestNavigation(() => { - saveEditorReturnPath(location.pathname + location.search); + saveEditorReturnPath(); navigate(PORTAL_BASENAME); }), }; diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index b69619b85d..3fe081ed82 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -5,6 +5,7 @@ import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; import { readPendingConnect } from "@app/routes/pendingConnect"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import { AuthShell } from "@app/auth/ui/AuthShell"; import ErrorMessage from "@app/auth/ui/ErrorMessage"; import { Spinner } from "@app/ui/Spinner"; @@ -136,18 +137,16 @@ export default function AuthCallback() { // else on the editor. // Explicit `next` first, so a sign-in started for another reason is not // hijacked by a remembered connect request. - const explicitNext = url.searchParams.get("next"); + const explicitNext = + url.searchParams.get("next") ?? url.searchParams.get("from"); const pendingConnect = readPendingConnect(); - const destination = - explicitNext && - explicitNext.startsWith("/") && - !explicitNext.startsWith("//") - ? explicitNext - : pendingConnect - ? `/link?request=${encodeURIComponent(pendingConnect)}` - : next.startsWith("/") && !next.startsWith("//") - ? next - : await resolveLandingPath(); + const destination = isSafePostLoginRedirect(explicitNext) + ? explicitNext + : pendingConnect + ? `/link?request=${encodeURIComponent(pendingConnect)}` + : isSafePostLoginRedirect(next) + ? next + : await resolveLandingPath(); console.log("[Auth Callback Debug] Redirecting to:", destination); setTimeout(() => navigate(destination, { replace: true }), 1500); diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 418616104e..ba251561fd 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -14,6 +14,7 @@ import { getBaseUrl, withBasePath, } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import LinkRoundedIcon from "@mui/icons-material/LinkRounded"; // Import login components @@ -48,14 +49,14 @@ export default function Login() { } }, []); - // Same-origin relative path to return to after login (e.g. the OAuth - // consent page). Same sanitization rules as AuthCallback's `next`. + // Same-origin router path to return to after login (e.g. the OAuth consent + // page, or the editor a 401 bounced the user off). `?next=` is what this app + // writes; `?from=` is what the shared core 401 handler writes. const nextPath = useMemo(() => { try { - const next = new URL(window.location.href).searchParams.get("next"); - return next && next.startsWith("/") && !next.startsWith("//") - ? next - : null; + const params = new URL(window.location.href).searchParams; + const candidate = params.get("next") ?? params.get("from"); + return isSafePostLoginRedirect(candidate) ? candidate : null; } catch (_) { return null; } diff --git a/frontend/editor/src/saas/services/apiClient.test.ts b/frontend/editor/src/saas/services/apiClient.test.ts index 45646bf8d8..af4a1d90f4 100644 --- a/frontend/editor/src/saas/services/apiClient.test.ts +++ b/frontend/editor/src/saas/services/apiClient.test.ts @@ -219,10 +219,11 @@ describe("apiClient", () => { // Import apiClient after mocking const { default: apiClient } = await import("@app/services/apiClient"); - // Mock window.location for redirect test + // On /editor when the session dies: the return path must ride along so the + // login screen sends the user back here, not to the role-based landing. Object.defineProperty(window, "location", { writable: true, - value: { href: "" }, + value: { href: "", pathname: "/editor", search: "" }, }); const mockAdapter = vi.fn((config) => { @@ -245,8 +246,50 @@ describe("apiClient", () => { } catch (_) { // Verify refresh was attempted expect(supabase.auth.refreshSession).toHaveBeenCalled(); - // Verify redirect to login - expect(window.location.href).toBe("/login"); + // Verify redirect to login carries the return path + expect(window.location.href).toBe("/login?next=%2Feditor"); + } + }); + + it("does not redirect (or loop) when already on the login page", async () => { + expectConsole.error(/\[API Client\] Token refresh failed/); + const oldSession = { access_token: "old", user: { id: "user-123" } }; + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: oldSession as unknown as Session }, + error: null, + }); + vi.mocked(supabase.auth.refreshSession).mockResolvedValue({ + data: { user: null, session: null }, + error: { + name: "AuthError", + message: "Refresh failed", + status: 400, + code: "auth_error", + } as unknown as AuthError, + }); + + const { default: apiClient } = await import("@app/services/apiClient"); + + Object.defineProperty(window, "location", { + writable: true, + value: { href: "", pathname: "/login", search: "?next=%2Feditor" }, + }); + + apiClient.defaults.adapter = vi.fn((config) => + Promise.reject( + Object.assign(new Error("Unauthorized"), { + response: { status: 401, data: { error: "Unauthorized" } }, + config, + }), + ), + ); + + try { + await apiClient.get("/api/v1/test"); + expect(true).toBe(false); + } catch (_) { + // Left untouched: no second redirect off the login page. + expect(window.location.href).toBe(""); } }); }); diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 64cd05fd01..73ebe97e47 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -5,8 +5,9 @@ import { classifyPaygError, handlePaygError, } from "@app/services/paygErrorInterceptor"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Helper: decode base64url JWT payload safely function decodeJwtPayload(token: string): Record | null { @@ -113,6 +114,21 @@ function refreshSessionOnce(): ReturnType { return inFlightRefresh; } +// Hard-redirect to /login, carrying where the user was so the login screen can +// return them there instead of falling through to the role-based landing (which +// sends processor users to the processor - the "refresh /editor bounces me to +// the processor" bug). Router-relative, matching what Login reads via `?next=`. +function redirectToLogin(): void { + const loginPath = withBasePath("/login"); + // Already on the login page: another redirect would just loop. + if (window.location.pathname === loginPath) return; + const returnPath = + stripBasePath(window.location.pathname) + window.location.search; + window.location.href = isSafePostLoginRedirect(returnPath) + ? `${loginPath}?next=${encodeURIComponent(returnPath)}` + : loginPath; +} + // Response interceptor for handling token refresh apiClient.interceptors.response.use( (response) => response, @@ -173,7 +189,7 @@ apiClient.interceptors.response.use( // The session genuinely can't be recovered. Send protected requests // to login; public ones just fail quietly (no redirect). if (!isPublicEndpoint) { - window.location.href = withBasePath("/login"); + redirectToLogin(); } return Promise.reject(error); @@ -194,10 +210,7 @@ apiClient.interceptors.response.use( console.debug( "[API Client] No session to refresh, 401 on protected endpoint", ); - const loginPath = withBasePath("/login"); - if (window.location.pathname !== loginPath) { - window.location.href = loginPath; - } + redirectToLogin(); return Promise.reject(error); } } catch (refreshError) {