mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Merge remote-tracking branch 'origin/main' into SaaS
# Conflicts: # frontend/editor/src/core/components/shared/AppConfigModal.tsx
This commit is contained in:
@@ -104,44 +104,44 @@ const AddFileCard = ({
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "2rem",
|
||||
height: "38px",
|
||||
paddingLeft: isUploadHover ? 0 : "1rem",
|
||||
paddingRight: isUploadHover ? 0 : "1rem",
|
||||
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
|
||||
minWidth: isUploadHover ? "58px" : undefined,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="add"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
className="text-[var(--accent-interactive)]"
|
||||
/>
|
||||
{!isUploadHover && (
|
||||
{!isUploadHover && (
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "2rem",
|
||||
height: "38px",
|
||||
paddingLeft: "1rem",
|
||||
paddingRight: "1rem",
|
||||
width: "calc(100% - 58px - 0.6rem)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="add"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
className="text-[var(--accent-interactive)]"
|
||||
/>
|
||||
<span>{t("landing.addFiles", "Add Files")}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={t("addFileCard.upload", "Upload")}
|
||||
title={terminology.uploadFromComputer}
|
||||
style={{
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "1rem",
|
||||
height: "38px",
|
||||
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
|
||||
width: isUploadHover ? "100%" : "58px",
|
||||
minWidth: "58px",
|
||||
paddingLeft: isUploadHover ? "1rem" : 0,
|
||||
paddingRight: isUploadHover ? "1rem" : 0,
|
||||
@@ -149,6 +149,7 @@ const AddFileCard = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
@@ -157,10 +158,18 @@ const AddFileCard = ({
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--accent-interactive)" }}
|
||||
style={{ color: "var(--accent-interactive)", flexShrink: 0 }}
|
||||
/>
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: ".5rem" }}>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: ".5rem",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -743,6 +743,7 @@ function FileCard({
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
@@ -773,6 +774,7 @@ function FileCard({
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
data-testid="file-menu-move-to"
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
@@ -1331,6 +1333,7 @@ function FileRow({
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -191,6 +191,7 @@ export function MoveToFolderDialog({
|
||||
return creatingFolder ? (
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<TextInput
|
||||
data-testid="move-dialog-new-folder-name"
|
||||
label={t(
|
||||
"filesPage.moveDialog.newFolderLabel",
|
||||
"New folder name",
|
||||
@@ -251,6 +252,7 @@ export function MoveToFolderDialog({
|
||||
setNewFolderName("");
|
||||
}}
|
||||
styles={{ root: { alignSelf: "flex-start" } }}
|
||||
data-testid="move-dialog-create-folder-toggle"
|
||||
>
|
||||
{t(
|
||||
"filesPage.moveDialog.newFolderToggle",
|
||||
|
||||
@@ -106,6 +106,15 @@
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-nav-item-badge {
|
||||
display: none;
|
||||
}
|
||||
.modal-nav-item:hover .modal-nav-item-badge,
|
||||
.modal-nav-item:focus-within .modal-nav-item-badge,
|
||||
.modal-nav-item.active .modal-nav-item-badge {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -24,19 +24,33 @@ import {
|
||||
useUnsavedChanges,
|
||||
} from "@app/contexts/UnsavedChangesContext";
|
||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const match = pathname.match(/\/settings\/([^/]+)/);
|
||||
if (match && match[1]) {
|
||||
const section = match[1] as NavKey;
|
||||
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState<NavKey>("general");
|
||||
// Initialize from the URL so a deep link (`/settings/people`) lands on the
|
||||
// right tab without a one-frame "general" flicker.
|
||||
const [active, setActive] = useState<NavKey>(
|
||||
() => getSectionFromPath(window.location.pathname) ?? "general",
|
||||
);
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -45,17 +59,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const { confirmIfDirty } = useUnsavedChanges();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const match = pathname.match(/\/settings\/([^/]+)/);
|
||||
if (match && match[1]) {
|
||||
const section = match[1] as NavKey;
|
||||
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Sync active state with URL path
|
||||
// Sync active state with URL path. Runs on open, on external URL changes,
|
||||
// and on the redirect path below - NOT on intra-modal tab clicks, because
|
||||
// those update the URL via `history.replaceState` directly and never push
|
||||
// a new React Router location.
|
||||
useEffect(() => {
|
||||
const section = getSectionFromPath(location.pathname);
|
||||
if (opened && section) {
|
||||
@@ -77,19 +84,42 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Handle custom events for backwards compatibility.
|
||||
// Use replace when already on /settings/* so external tab-switches
|
||||
// don't pile up history entries that would break close-by-back.
|
||||
// Switch tab without forcing every `useLocation()` subscriber (HomePage and
|
||||
// its FileSidebar/Workbench/RightSidebar/FileManager tree) to re-render.
|
||||
//
|
||||
// First entry into /settings/* still goes through React Router so HomePage's
|
||||
// location-watching effect opens the modal and pushes a real history entry -
|
||||
// so the back button can close us. Subsequent tab clicks bypass React Router
|
||||
// and mutate the URL bar via `history.replaceState`. The browser sees the
|
||||
// URL update (deep-link / refresh still work) but React Router never fires a
|
||||
// location change, so the layer behind the Mantine overlay never repaints
|
||||
// and the backdrop-filter blur stops flashing.
|
||||
const switchSection = useCallback(
|
||||
(key: NavKey) => {
|
||||
setActive(key);
|
||||
const alreadyInSettings = stripBasePath(
|
||||
window.location.pathname,
|
||||
).startsWith("/settings");
|
||||
if (alreadyInSettings) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
withBasePath(`/settings/${key}`),
|
||||
);
|
||||
} else {
|
||||
navigate(`/settings/${key}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// Backwards-compat: external `appConfig:navigate` events route through the
|
||||
// same switchSection path so they get the no-flash treatment too.
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
const alreadyInSettings = stripBasePath(
|
||||
window.location.pathname,
|
||||
).startsWith("/settings");
|
||||
navigate(`/settings/${detail.key}`, {
|
||||
replace: alreadyInSettings,
|
||||
});
|
||||
switchSection(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
@@ -98,7 +128,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
"appConfig:navigate",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, [navigate]);
|
||||
}, [switchSection]);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
@@ -167,23 +197,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
async (key: NavKey) => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
setActive(key);
|
||||
// First in-modal nav (when current path isn't `/settings/*` yet) must
|
||||
// PUSH so the originating page stays in history and close-by-back can
|
||||
// return to it. Subsequent tab switches REPLACE so they don't pile up
|
||||
// history entries that handleClose's navigate(-1) can't unwind.
|
||||
//
|
||||
// Read window.location.pathname directly (not the React hook's
|
||||
// location.pathname) so rapid successive clicks pick up the URL
|
||||
// change from the previous click immediately. The hook snapshot is
|
||||
// stale between render cycles - relying on it lets a second click
|
||||
// PUSH again before React re-renders, producing a history pile-up.
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
navigate(`/settings/${key}`, { replace: alreadyInSettings });
|
||||
switchSection(key);
|
||||
},
|
||||
[confirmIfDirty, navigate],
|
||||
[confirmIfDirty, switchSection],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -244,7 +260,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
handleNavigation(item.key);
|
||||
}
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
className={`modal-nav-item ${isActive ? "active" : ""} ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive
|
||||
? colors.navItemActiveBg
|
||||
@@ -261,8 +277,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
style={{ color }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
<Group
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
style={{ color, minWidth: 0, flex: 1 }}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.badge && (
|
||||
@@ -270,6 +297,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={item.badgeColor ?? "orange"}
|
||||
className="modal-nav-item-badge"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{item.badge}
|
||||
|
||||
@@ -97,9 +97,16 @@
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (theme / language / download) ---- */
|
||||
|
||||
@@ -492,7 +492,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.theme", "Theme")}
|
||||
</Text>
|
||||
@@ -527,7 +527,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.language", "Language")}
|
||||
</Text>
|
||||
@@ -552,7 +552,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.defaultToolPickerMode",
|
||||
@@ -590,7 +590,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.defaultStartupView",
|
||||
@@ -632,7 +632,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
|
||||
</Text>
|
||||
@@ -687,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableTools",
|
||||
@@ -718,7 +718,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableConversions",
|
||||
@@ -759,7 +759,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
|
||||
</Text>
|
||||
@@ -796,7 +796,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.autoUnzipFileLimit",
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function ProviderCard({
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{field.label}
|
||||
</Text>
|
||||
|
||||
@@ -91,7 +91,18 @@ const OperationButton = ({
|
||||
color={color}
|
||||
data-testid={dataTestId}
|
||||
data-tour={dataTour}
|
||||
style={{ minHeight: "2.5rem", position: "relative" }}
|
||||
style={{ minHeight: "2.5rem", height: "auto", position: "relative" }}
|
||||
styles={{
|
||||
label: {
|
||||
whiteSpace: "normal",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
inner: {
|
||||
paddingTop: "0.25rem",
|
||||
paddingBottom: "0.25rem",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isLoading
|
||||
? loadingText || t("loading", "Loading...")
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
Button,
|
||||
Paper,
|
||||
Group,
|
||||
Menu,
|
||||
NumberInput,
|
||||
Slider,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useIsPhone } from "@app/hooks/useIsMobile";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
|
||||
@@ -21,6 +23,7 @@ import WbSunnyIcon from "@mui/icons-material/WbSunny";
|
||||
import WbTwilightIcon from "@mui/icons-material/WbTwilight";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
|
||||
interface PdfViewerToolbarProps {
|
||||
// Page navigation props (placeholders for now)
|
||||
@@ -35,6 +38,9 @@ export function PdfViewerToolbar({
|
||||
onPageChange,
|
||||
}: PdfViewerToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const isPhone = useIsPhone();
|
||||
const buttonMinWidth = isPhone ? "3rem" : "2.5rem";
|
||||
const buttonSize = isPhone ? "lg" : "md";
|
||||
const {
|
||||
getScrollState,
|
||||
getZoomState,
|
||||
@@ -141,41 +147,45 @@ export function PdfViewerToolbar({
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
rowGap: 8,
|
||||
gap: 10,
|
||||
justifyContent: "center",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
|
||||
pointerEvents: "auto",
|
||||
minWidth: "30rem",
|
||||
}}
|
||||
>
|
||||
{/* First Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleFirstPage}
|
||||
disabled={scrollState.currentPage === 1}
|
||||
style={{ minWidth: "2.5rem" }}
|
||||
title={t("viewer.firstPage", "First Page")}
|
||||
>
|
||||
<FirstPageIcon fontSize="small" />
|
||||
</Button>
|
||||
{!isPhone && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size={buttonSize}
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleFirstPage}
|
||||
disabled={scrollState.currentPage === 1}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
title={t("viewer.firstPage", "First Page")}
|
||||
>
|
||||
<FirstPageIcon fontSize="small" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Previous Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
size={buttonSize}
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={scrollState.currentPage === 1}
|
||||
style={{ minWidth: "2.5rem" }}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
title={t("viewer.previousPage", "Previous Page")}
|
||||
>
|
||||
<ArrowBackIosIcon fontSize="small" />
|
||||
@@ -212,137 +222,242 @@ export function PdfViewerToolbar({
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
size={buttonSize}
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleNextPage}
|
||||
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||
style={{ minWidth: "2.5rem" }}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
title={t("viewer.nextPage", "Next Page")}
|
||||
>
|
||||
<ArrowForwardIosIcon fontSize="small" />
|
||||
</Button>
|
||||
|
||||
{/* Last Page Button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="md"
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleLastPage}
|
||||
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||
style={{ minWidth: "2.5rem" }}
|
||||
title={t("viewer.lastPage", "Last Page")}
|
||||
>
|
||||
<LastPageIcon fontSize="small" />
|
||||
</Button>
|
||||
{!isPhone && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size={buttonSize}
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={handleLastPage}
|
||||
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
title={t("viewer.lastPage", "Last Page")}
|
||||
>
|
||||
<LastPageIcon fontSize="small" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Dual Page Toggle */}
|
||||
<Tooltip
|
||||
content={
|
||||
isDualPageActive
|
||||
? t("viewer.singlePageView", "Single Page View")
|
||||
: t("viewer.dualPageView", "Dual Page View")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
style={{ minWidth: "2.5rem" }}
|
||||
{!isPhone && (
|
||||
<Tooltip
|
||||
content={
|
||||
isDualPageActive
|
||||
? t("viewer.singlePageView", "Single Page View")
|
||||
: t("viewer.dualPageView", "Dual Page View")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
{isDualPageActive ? (
|
||||
<DescriptionIcon fontSize="small" />
|
||||
) : (
|
||||
<ViewWeekIcon fontSize="small" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size={buttonSize}
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
>
|
||||
{isDualPageActive ? (
|
||||
<DescriptionIcon fontSize="small" />
|
||||
) : (
|
||||
<ViewWeekIcon fontSize="small" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* PDF Render Mode Toggle */}
|
||||
<Tooltip
|
||||
content={
|
||||
pdfRenderMode === "normal"
|
||||
? t("viewer.enableDarkFilter", "Enable Dark Filter")
|
||||
: pdfRenderMode === "dark"
|
||||
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
|
||||
: t("viewer.disableColorFilter", "Disable Color Filter")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
<Button
|
||||
variant={pdfRenderMode !== "normal" ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={cyclePdfRenderMode}
|
||||
style={{ minWidth: "2.5rem" }}
|
||||
aria-label={
|
||||
{!isPhone && (
|
||||
<Tooltip
|
||||
content={
|
||||
pdfRenderMode === "normal"
|
||||
? t("viewer.enableDarkFilter", "Enable Dark Filter")
|
||||
: pdfRenderMode === "dark"
|
||||
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
|
||||
: t("viewer.disableColorFilter", "Disable Color Filter")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
{pdfRenderMode === "normal" && <DarkModeIcon fontSize="small" />}
|
||||
{pdfRenderMode === "dark" && <WbTwilightIcon fontSize="small" />}
|
||||
{pdfRenderMode === "sepia" && <WbSunnyIcon fontSize="small" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant={pdfRenderMode !== "normal" ? "filled" : "light"}
|
||||
color="blue"
|
||||
size={buttonSize}
|
||||
radius="xl"
|
||||
onClick={cyclePdfRenderMode}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
aria-label={
|
||||
pdfRenderMode === "normal"
|
||||
? t("viewer.enableDarkFilter", "Enable Dark Filter")
|
||||
: pdfRenderMode === "dark"
|
||||
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
|
||||
: t("viewer.disableColorFilter", "Disable Color Filter")
|
||||
}
|
||||
>
|
||||
{pdfRenderMode === "normal" && <DarkModeIcon fontSize="small" />}
|
||||
{pdfRenderMode === "dark" && <WbTwilightIcon fontSize="small" />}
|
||||
{pdfRenderMode === "sepia" && <WbSunnyIcon fontSize="small" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
radius="md"
|
||||
onClick={handleZoomOut}
|
||||
aria-label={t("viewer.zoomOut", "Zoom out")}
|
||||
{/* Desktop zoom controls (slider + buttons) */}
|
||||
{!isPhone && (
|
||||
<Group
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ marginLeft: 16, flexShrink: 0 }}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<Slider
|
||||
value={Math.min(Math.max(displayZoomPercent, 20), 500)}
|
||||
min={20}
|
||||
max={500}
|
||||
step={5}
|
||||
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: { width: "6rem" },
|
||||
thumb: { width: 14, height: 14 },
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
radius="md"
|
||||
onClick={handleZoomIn}
|
||||
aria-label={t("viewer.zoomIn", "Zoom in")}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
radius="md"
|
||||
onClick={handleZoomOut}
|
||||
aria-label={t("viewer.zoomOut", "Zoom out")}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<Slider
|
||||
value={Math.min(Math.max(displayZoomPercent, 20), 500)}
|
||||
min={20}
|
||||
max={500}
|
||||
step={5}
|
||||
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: { minWidth: "6rem", width: "6rem", flexShrink: 0 },
|
||||
thumb: { width: 14, height: 14 },
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
radius="md"
|
||||
onClick={handleZoomIn}
|
||||
aria-label={t("viewer.zoomIn", "Zoom in")}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<span
|
||||
style={{
|
||||
minWidth: "2.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{displayZoomPercent}%
|
||||
</span>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{isPhone && (
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={240}
|
||||
position="top-end"
|
||||
closeOnItemClick={false}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<span
|
||||
style={{
|
||||
minWidth: "2.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{displayZoomPercent}%
|
||||
</span>
|
||||
</Group>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
size="lg"
|
||||
aria-label={t("viewer.moreOptions", "More")}
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{t("viewer.pageNavigation", "Page navigation")}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<FirstPageIcon fontSize="small" />}
|
||||
disabled={scrollState.currentPage === 1}
|
||||
onClick={handleFirstPage}
|
||||
>
|
||||
{t("viewer.firstPage", "First page")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LastPageIcon fontSize="small" />}
|
||||
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||
onClick={handleLastPage}
|
||||
>
|
||||
{t("viewer.lastPage", "Last page")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("viewer.zoom", "Zoom")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<ZoomOutIcon fontSize="small" />}
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
{t("viewer.zoomOut", "Zoom out")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<ZoomInIcon fontSize="small" />}
|
||||
onClick={handleZoomIn}
|
||||
>
|
||||
{t("viewer.zoomIn", "Zoom in")} ({displayZoomPercent}%)
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("viewer.view", "View")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
isDualPageActive ? (
|
||||
<DescriptionIcon fontSize="small" />
|
||||
) : (
|
||||
<ViewWeekIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
onClick={handleDualPageToggle}
|
||||
>
|
||||
{isDualPageActive
|
||||
? t("viewer.singlePageView", "Single Page View")
|
||||
: t("viewer.dualPageView", "Dual Page View")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
pdfRenderMode === "normal" ? (
|
||||
<DarkModeIcon fontSize="small" />
|
||||
) : pdfRenderMode === "dark" ? (
|
||||
<WbTwilightIcon fontSize="small" />
|
||||
) : (
|
||||
<WbSunnyIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
onClick={cyclePdfRenderMode}
|
||||
>
|
||||
{pdfRenderMode === "normal"
|
||||
? t("viewer.enableDarkFilter", "Enable Dark Filter")
|
||||
: pdfRenderMode === "dark"
|
||||
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
|
||||
: t("viewer.disableColorFilter", "Disable Color Filter")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useAppConfig,
|
||||
} from "@app/contexts/AppConfigContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
// Mock apiClient
|
||||
@@ -92,6 +93,7 @@ describe("AppConfigContext", () => {
|
||||
});
|
||||
|
||||
it("should handle network errors", async () => {
|
||||
expectConsole.error(/\[AppConfig\] Failed to fetch app config/);
|
||||
const errorMessage = "Network error occurred";
|
||||
const mockError = new Error(errorMessage);
|
||||
// Network errors don't have response property
|
||||
@@ -249,6 +251,10 @@ describe("AppConfigContext", () => {
|
||||
});
|
||||
|
||||
it("should handle initial config prop", async () => {
|
||||
// apiClient.get isn't mocked here, so the implicit "fetch fails" path
|
||||
// exhausts retries and logs. Test only asserts the API was called, so
|
||||
// the failure log is incidental.
|
||||
allowConsole.error(/\[AppConfig\] Failed to fetch app config/);
|
||||
const initialConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: "Initial App",
|
||||
|
||||
@@ -107,12 +107,10 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
|
||||
if (attempt > 0) {
|
||||
const delay = initialDelay * Math.pow(2, attempt - 1);
|
||||
console.log(
|
||||
console.debug(
|
||||
`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`,
|
||||
);
|
||||
await sleep(delay);
|
||||
} else {
|
||||
console.log("[AppConfig] Fetching app config...");
|
||||
}
|
||||
|
||||
// apiClient automatically adds JWT header if available via interceptors
|
||||
@@ -165,7 +163,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
(!status || status >= 500) && attempt < maxRetries;
|
||||
|
||||
if (shouldRetry) {
|
||||
console.warn(
|
||||
console.debug(
|
||||
`[AppConfig] Attempt ${attempt + 1} failed (status ${status || "network error"}):`,
|
||||
err.message,
|
||||
"- will retry...",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
|
||||
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
|
||||
import { createFolderId, FolderId, FolderRecord } from "@app/types/folder";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
/**
|
||||
* Regression test for the sync-banner 4xx gating fix in commit c38b646c5.
|
||||
@@ -25,21 +27,43 @@ import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
|
||||
// require a running backend (folderSyncService) and a populated IDB
|
||||
// (folderStorage). Both are out of scope for testing the error gate.
|
||||
const mockList = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
vi.mock("@app/services/folderSyncService", () => ({
|
||||
folderSyncService: {
|
||||
list: () => mockList(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
update: (...args: unknown[]) => mockUpdate(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
|
||||
// after every state change, so a stateless [] mock would clobber pull results.
|
||||
const { mockIdb } = vi.hoisted(() => ({
|
||||
mockIdb: { folders: [] as { id: string }[] },
|
||||
}));
|
||||
vi.mock("@app/services/folderStorage", () => ({
|
||||
folderStorage: {
|
||||
getAllFolders: vi.fn().mockResolvedValue([]),
|
||||
replaceAll: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
getAllFolders: vi.fn(() => Promise.resolve([...mockIdb.folders])),
|
||||
replaceAll: vi.fn((next: { id: string }[]) => {
|
||||
mockIdb.folders = [...next];
|
||||
return Promise.resolve();
|
||||
}),
|
||||
upsert: vi.fn(),
|
||||
upsertFolder: vi.fn((next: { id: string }) => {
|
||||
mockIdb.folders = [
|
||||
...mockIdb.folders.filter((f) => f.id !== next.id),
|
||||
next,
|
||||
];
|
||||
return Promise.resolve();
|
||||
}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
removeFolders: vi.fn((ids: string[]) => {
|
||||
const drop = new Set(ids);
|
||||
mockIdb.folders = mockIdb.folders.filter((f) => !drop.has(f.id));
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -104,6 +128,8 @@ async function renderAndWaitForPull(): Promise<void> {
|
||||
describe("FolderContext sync-banner gating", () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockDelete.mockReset();
|
||||
});
|
||||
|
||||
test("401 (unauthorized) does NOT surface a banner", async () => {
|
||||
@@ -122,6 +148,9 @@ describe("FolderContext sync-banner gating", () => {
|
||||
});
|
||||
|
||||
test("500 (server error) DOES surface a banner", async () => {
|
||||
// Surfacing the banner also logs a warning - that's the contract this branch
|
||||
// tests for.
|
||||
expectConsole.warn(/\[FolderContext\] pullFromServer failed/);
|
||||
mockList.mockRejectedValue(axiosError(500, "internal"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toContain(
|
||||
@@ -133,7 +162,8 @@ describe("FolderContext sync-banner gating", () => {
|
||||
test("network error (no response) DOES surface a banner", async () => {
|
||||
// axios on a network failure rejects with an Error that has NO
|
||||
// `.response` property - that's the "status === undefined" branch
|
||||
// the gate explicitly covers.
|
||||
// the gate explicitly covers. Surfacing the banner also logs a warning.
|
||||
expectConsole.warn(/\[FolderContext\] pullFromServer failed/);
|
||||
mockList.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toContain(
|
||||
@@ -152,3 +182,163 @@ describe("FolderContext sync-banner gating", () => {
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
/** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
|
||||
describe("FolderContext stale-folder 404 cleanup", () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockDelete.mockReset();
|
||||
// Reset the stateful IDB mock so each test starts with an empty cache.
|
||||
mockIdb.folders = [];
|
||||
});
|
||||
|
||||
function makeFolder(name: string, parentFolderId: FolderId | null = null) {
|
||||
return {
|
||||
id: createFolderId(),
|
||||
name,
|
||||
parentFolderId,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
} as FolderRecord;
|
||||
}
|
||||
|
||||
type ProbeApi = {
|
||||
error: string | null;
|
||||
folderCount: number;
|
||||
currentFolderId: FolderId | null;
|
||||
setCurrentFolderId: (id: FolderId | null) => void;
|
||||
rename: (id: FolderId, name: string) => Promise<unknown>;
|
||||
delete: (id: FolderId) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function ApiProbe(props: { onReady: (api: ProbeApi) => void }) {
|
||||
const f = useFolders();
|
||||
React.useEffect(() => {
|
||||
props.onReady({
|
||||
error: f.error,
|
||||
folderCount: f.folders.length,
|
||||
currentFolderId: f.currentFolderId,
|
||||
setCurrentFolderId: f.setCurrentFolderId,
|
||||
rename: (id, name) => f.renameFolder(id, name),
|
||||
delete: (id) => f.deleteFolder(id),
|
||||
});
|
||||
}, [f, props]);
|
||||
return (
|
||||
<>
|
||||
<div data-testid="error">{f.error ?? "<null>"}</div>
|
||||
<div data-testid="reachable">{String(f.serverReachable)}</div>
|
||||
<div data-testid="count">{f.folders.length}</div>
|
||||
<div data-testid="current">{f.currentFolderId ?? "<null>"}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function setupWithFolders(
|
||||
initial: FolderRecord[],
|
||||
): Promise<{ current: ProbeApi }> {
|
||||
// Ref (not plain object) so tests see the latest f-bound api after re-renders.
|
||||
mockList.mockResolvedValueOnce(initial);
|
||||
const apiRef: { current: ProbeApi | null } = { current: null };
|
||||
render(
|
||||
<FolderProvider>
|
||||
<ApiProbe onReady={(api) => (apiRef.current = api)} />
|
||||
</FolderProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("count").textContent).toBe(
|
||||
String(initial.length),
|
||||
),
|
||||
);
|
||||
if (!apiRef.current) throw new Error("ApiProbe never reported ready");
|
||||
return apiRef as { current: ProbeApi };
|
||||
}
|
||||
|
||||
test("renameFolder 404 silently drops folder + descendants, no banner", async () => {
|
||||
const parent = makeFolder("parent");
|
||||
const child = makeFolder("child", parent.id);
|
||||
const sibling = makeFolder("sibling");
|
||||
const api = await setupWithFolders([parent, child, sibling]);
|
||||
|
||||
// Convergence pull returns just sibling after the local drop.
|
||||
mockList.mockResolvedValueOnce([sibling]);
|
||||
mockUpdate.mockRejectedValueOnce(axiosError(404, "Folder not found"));
|
||||
|
||||
await act(async () => {
|
||||
const result = await api.current.rename(parent.id, "newname");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("count").textContent).toBe("1"),
|
||||
);
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("true");
|
||||
});
|
||||
|
||||
test("deleteFolder 404 is treated as already-deleted, returns [id], no banner", async () => {
|
||||
const target = makeFolder("target");
|
||||
const other = makeFolder("other");
|
||||
const api = await setupWithFolders([target, other]);
|
||||
|
||||
mockList.mockResolvedValueOnce([other]);
|
||||
mockDelete.mockRejectedValueOnce(axiosError(404, "Folder not found"));
|
||||
|
||||
let result: unknown;
|
||||
await act(async () => {
|
||||
result = await api.current.delete(target.id);
|
||||
});
|
||||
expect(result).toEqual([target.id]);
|
||||
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("count").textContent).toBe("1"),
|
||||
);
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("true");
|
||||
});
|
||||
|
||||
test("stale 404 strand-resets currentFolderId when user is inside the dropped subtree", async () => {
|
||||
// User is parked inside the about-to-be-deleted subtree → must navigate out.
|
||||
const parent = makeFolder("parent");
|
||||
const child = makeFolder("child", parent.id);
|
||||
const sibling = makeFolder("sibling");
|
||||
const api = await setupWithFolders([parent, child, sibling]);
|
||||
|
||||
act(() => {
|
||||
api.current.setCurrentFolderId(child.id);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("current").textContent).toBe(child.id),
|
||||
);
|
||||
|
||||
mockList.mockResolvedValueOnce([sibling]);
|
||||
mockUpdate.mockRejectedValueOnce(axiosError(404, "Folder not found"));
|
||||
|
||||
await act(async () => {
|
||||
await api.current.rename(parent.id, "doomed-rename");
|
||||
});
|
||||
|
||||
// ROOT_FOLDER_ID is null → probe renders "<null>".
|
||||
expect(screen.getByTestId("current").textContent).toBe("<null>");
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
});
|
||||
|
||||
test("non-404 mutation errors still surface (regression guard)", async () => {
|
||||
const target = makeFolder("target");
|
||||
const api = await setupWithFolders([target]);
|
||||
|
||||
mockUpdate.mockRejectedValueOnce(axiosError(500, "boom"));
|
||||
|
||||
let threw = false;
|
||||
await act(async () => {
|
||||
try {
|
||||
await api.current.rename(target.id, "newname");
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
});
|
||||
expect(threw).toBe(true);
|
||||
expect(screen.getByTestId("count").textContent).toBe("1");
|
||||
expect(screen.getByTestId("error").textContent).not.toBe("<null>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,40 @@ function reachabilityFromError(err: unknown): boolean {
|
||||
return status !== undefined && status >= 400 && status < 500;
|
||||
}
|
||||
|
||||
/** Root + every local descendant via parentFolderId. Bounded for corrupted chains. */
|
||||
function collectLocalSubtreeIds(
|
||||
rootId: FolderId,
|
||||
folders: FolderRecord[],
|
||||
): Set<FolderId> {
|
||||
const childrenByParent = new Map<FolderId, FolderId[]>();
|
||||
for (const f of folders) {
|
||||
if (f.parentFolderId === null) continue;
|
||||
const list = childrenByParent.get(f.parentFolderId) ?? [];
|
||||
list.push(f.id);
|
||||
childrenByParent.set(f.parentFolderId, list);
|
||||
}
|
||||
const result = new Set<FolderId>([rootId]);
|
||||
const stack: FolderId[] = [rootId];
|
||||
const MAX_LOCAL_SUBTREE_NODES = 10_000;
|
||||
while (stack.length > 0 && result.size < MAX_LOCAL_SUBTREE_NODES) {
|
||||
const cur = stack.pop()!;
|
||||
const children = childrenByParent.get(cur);
|
||||
if (!children) continue;
|
||||
for (const childId of children) {
|
||||
if (!result.has(childId)) {
|
||||
result.add(childId);
|
||||
stack.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Extract HTTP status off an axios-style error, or undefined on network failure. */
|
||||
function errorStatus(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } })?.response?.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `currentId` or any of its ancestors is in `removedSet`. Walks up via
|
||||
* `parentFolderId` using the pre-removal `folders` snapshot so the chain is
|
||||
@@ -272,7 +306,6 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
if (mountedRef.current) setServerReachable(false);
|
||||
return { ok: false, reason: "endpoint-missing" };
|
||||
}
|
||||
console.warn("[FolderContext] pullFromServer failed", err);
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(false);
|
||||
// Only surface a banner when this is a server-side outage or
|
||||
@@ -284,6 +317,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
// manager. Folder-mutation buttons get individual disabled
|
||||
// tooltips via `serverReachable`, which is enough signal.
|
||||
if (status === undefined || status >= 500) {
|
||||
console.warn("[FolderContext] pullFromServer failed", err);
|
||||
setError(`Folder sync failed: ${formatServerError(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -390,24 +424,58 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
|
||||
// ─── mutations: server-first, cache update on success ──────────────
|
||||
|
||||
// Lifted up so handleStaleFolder below can reuse the file-detach primitive.
|
||||
const { clearFolderForFiles } = useIndexedDB();
|
||||
|
||||
/** Treat a per-folder 404 as "deleted elsewhere": drop subtree, strand-reset, pull. */
|
||||
const handleStaleFolder = useCallback(
|
||||
(staleId: FolderId, foldersSnapshot: FolderRecord[]) => {
|
||||
const subtree = collectLocalSubtreeIds(staleId, foldersSnapshot);
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(true);
|
||||
setError(null);
|
||||
setFolders((prev) => prev.filter((f) => !subtree.has(f.id)));
|
||||
if (
|
||||
currentFolderId !== null &&
|
||||
shouldStrandedReset(currentFolderId, subtree, foldersSnapshot)
|
||||
) {
|
||||
setCurrentFolderId(ROOT_FOLDER_ID);
|
||||
}
|
||||
}
|
||||
const subtreeIds = [...subtree];
|
||||
// Best-effort - pullFromServer is authoritative if these miss.
|
||||
void folderStorage
|
||||
.removeFolders(subtreeIds)
|
||||
.catch((e) => console.warn("[FolderContext] stale cache cleanup", e));
|
||||
void clearFolderForFiles(subtreeIds).catch((e) =>
|
||||
console.warn("[FolderContext] stale file-folder cleanup", e),
|
||||
);
|
||||
bumpFolderRevision();
|
||||
void pullFromServer();
|
||||
},
|
||||
[bumpFolderRevision, clearFolderForFiles, currentFolderId, pullFromServer],
|
||||
);
|
||||
|
||||
/**
|
||||
* Centralised mutation wrapper:
|
||||
* 1. Calls the server op.
|
||||
* 2. On success: flips `serverReachable=true`, updates in-memory state
|
||||
* from the server response, then best-effort writes the cache (cache
|
||||
* failure does NOT roll back; the in-memory truth came from the server).
|
||||
* 3. On failure: updates `serverReachable` per the error class, surfaces
|
||||
* via {@link setError}, re-throws so the caller's dialog can stay open.
|
||||
* Server-first mutation wrapper. On 404 with `staleFolderId`, hands off to
|
||||
* handleStaleFolder and resolves `null`; other errors surface + re-throw.
|
||||
*/
|
||||
const runFolderMutation = useCallback(
|
||||
async <T,>(
|
||||
serverOp: () => Promise<T>,
|
||||
onSuccess: (result: T) => void | Promise<void>,
|
||||
): Promise<T> => {
|
||||
staleFolderId: FolderId | null = null,
|
||||
): Promise<T | null> => {
|
||||
let result: T;
|
||||
try {
|
||||
result = await serverOp();
|
||||
} catch (err) {
|
||||
if (errorStatus(err) === 404 && staleFolderId !== null) {
|
||||
// `folders` is a closure snapshot; functional setFolders + the pull
|
||||
// keep this race-safe even if a concurrent mutation shifted state.
|
||||
handleStaleFolder(staleFolderId, folders);
|
||||
return null;
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(reachabilityFromError(err));
|
||||
setError(formatServerError(err));
|
||||
@@ -421,18 +489,13 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
try {
|
||||
await onSuccess(result);
|
||||
} catch (cacheErr) {
|
||||
// The server is authoritative - a cache write failure must not be
|
||||
// surfaced as if the operation failed. Log + leave the in-memory
|
||||
// state authoritative; next pullFromServer will re-seed the cache.
|
||||
console.warn(
|
||||
"[FolderContext] cache update failed after successful mutation",
|
||||
cacheErr,
|
||||
);
|
||||
// Cache write failure is non-fatal; next pull re-seeds.
|
||||
console.warn("[FolderContext] cache update after mutation", cacheErr);
|
||||
}
|
||||
bumpFolderRevision();
|
||||
return result;
|
||||
},
|
||||
[bumpFolderRevision],
|
||||
[bumpFolderRevision, folders, handleStaleFolder],
|
||||
);
|
||||
|
||||
const createFolder = useCallback(
|
||||
@@ -441,10 +504,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
parentFolderId: FolderId | null = currentFolderId,
|
||||
): Promise<FolderRecord> => {
|
||||
const color = pickFolderColor(name);
|
||||
// Generate id client-side so the server's idempotency check makes
|
||||
// retries safe (network blip → second POST returns the same row).
|
||||
// Client-side id makes server idempotency check safe on retry.
|
||||
const id = createFolderId();
|
||||
return runFolderMutation(
|
||||
const result = await runFolderMutation(
|
||||
() =>
|
||||
folderSyncService.create({
|
||||
id,
|
||||
@@ -460,6 +522,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
);
|
||||
// No staleFolderId passed → null branch can't fire; defensive throw.
|
||||
if (result === null) {
|
||||
throw new Error("createFolder unexpectedly returned null");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[currentFolderId, runFolderMutation],
|
||||
);
|
||||
@@ -474,6 +541,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
id,
|
||||
);
|
||||
},
|
||||
[runFolderMutation],
|
||||
@@ -493,6 +561,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
id,
|
||||
);
|
||||
},
|
||||
[runFolderMutation],
|
||||
@@ -515,13 +584,12 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
id,
|
||||
);
|
||||
},
|
||||
[runFolderMutation],
|
||||
);
|
||||
|
||||
const { clearFolderForFiles } = useIndexedDB();
|
||||
|
||||
const deleteFolder = useCallback(
|
||||
async (id: FolderId): Promise<FolderId[]> => {
|
||||
// Custom path (not runFolderMutation) because we have two best-effort
|
||||
@@ -532,6 +600,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
try {
|
||||
removed = await folderSyncService.delete(id);
|
||||
} catch (err) {
|
||||
if (errorStatus(err) === 404) {
|
||||
// Already gone; treat as success. Return [id]; pull is authoritative.
|
||||
handleStaleFolder(id, folders);
|
||||
return [id];
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(reachabilityFromError(err));
|
||||
setError(formatServerError(err));
|
||||
@@ -580,7 +653,13 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
[bumpFolderRevision, clearFolderForFiles, currentFolderId, folders],
|
||||
[
|
||||
bumpFolderRevision,
|
||||
clearFolderForFiles,
|
||||
currentFolderId,
|
||||
folders,
|
||||
handleStaleFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const value = useMemo<FolderContextValue>(
|
||||
|
||||
@@ -27,7 +27,7 @@ export function buildInputTracking(
|
||||
inputFileIds.push(fileId);
|
||||
inputStirlingFileStubs.push(record);
|
||||
} else {
|
||||
console.warn(`No file stub found for file: ${file.name}`);
|
||||
console.debug(`No file stub found for file: ${file.name}`);
|
||||
inputFileIds.push(fileId);
|
||||
inputStirlingFileStubs.push(createNewStirlingFileStub(file, fileId));
|
||||
}
|
||||
|
||||
@@ -15,3 +15,11 @@ export const useIsMobile = (): boolean => {
|
||||
export const useIsPhone = (): boolean => {
|
||||
return useMediaQuery("(max-width: 768px)") ?? false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to detect a coarse pointer (touch device)
|
||||
* Use to gate touch-only UI hints when combined with useIsMobile
|
||||
*/
|
||||
export const useIsTouch = (): boolean => {
|
||||
return useMediaQuery("(pointer: coarse)") ?? false;
|
||||
};
|
||||
|
||||
@@ -29,9 +29,6 @@ export function useScarfTracking() {
|
||||
// Listen to cookie consent changes and auto-fire pixel when consent is granted
|
||||
useEffect(() => {
|
||||
const handleConsentChange = () => {
|
||||
console.warn(
|
||||
"[useScarfTracking] Consent changed, checking scarf service acceptance",
|
||||
);
|
||||
if (isServiceAccepted("scarf", "analytics")) {
|
||||
firePixel(window.location.pathname);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Group } from "@mantine/core";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { useBaseUrl } from "@app/hooks/useBaseUrl";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useIsMobile, useIsTouch } from "@app/hooks/useIsMobile";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
@@ -84,6 +84,7 @@ export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useAppConfig();
|
||||
const isMobile = useIsMobile();
|
||||
const isTouch = useIsTouch();
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
@@ -352,12 +353,14 @@ export default function HomePage() {
|
||||
{t("home.mobile.workspace", "Workspace")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mobile-toggle-hint">
|
||||
{t(
|
||||
"home.mobile.swipeHint",
|
||||
"Swipe left or right to switch views",
|
||||
)}
|
||||
</span>
|
||||
{isTouch && (
|
||||
<span className="mobile-toggle-hint">
|
||||
{t(
|
||||
"home.mobile.swipeHint",
|
||||
"Swipe left or right to switch views",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{navigationState.workbench === "myFiles" ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test, beforeEach } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
import {
|
||||
DATABASE_CONFIGS,
|
||||
@@ -363,6 +364,9 @@ describe("IndexedDB migration (FILES store)", () => {
|
||||
});
|
||||
|
||||
test("SaaS v6 database is force-deleted (data lost, schema reset to v9)", async () => {
|
||||
// The force-delete warn IS the contract under test; production fires it
|
||||
// to surface why data was wiped.
|
||||
expectConsole.warn(/Deleting corrupt SaaS v6/);
|
||||
await seedSaasDatabase(6, ["v6-corrupt-file"]);
|
||||
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
@@ -381,6 +385,9 @@ describe("IndexedDB migration (FILES store)", () => {
|
||||
});
|
||||
|
||||
test("SaaS v7 database is force-deleted (data lost, schema reset to v9)", async () => {
|
||||
// The force-delete warn IS the contract under test; production fires it
|
||||
// to surface why data was wiped.
|
||||
expectConsole.warn(/Deleting corrupt SaaS v7/);
|
||||
await seedSaasDatabase(7, ["v7-corrupt-file"]);
|
||||
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
|
||||
@@ -2,22 +2,19 @@ import { BASE_PATH } from "@app/constants/app";
|
||||
import pdfiumWasmAssetUrl from "@embedpdf/pdfium/pdfium.wasm?url";
|
||||
|
||||
const getWasmUrl = (): string => {
|
||||
if (
|
||||
pdfiumWasmAssetUrl.startsWith("http://") ||
|
||||
pdfiumWasmAssetUrl.startsWith("https://") ||
|
||||
pdfiumWasmAssetUrl.startsWith("//")
|
||||
) {
|
||||
return pdfiumWasmAssetUrl;
|
||||
}
|
||||
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
// In dev, Vite serves the statically-copied asset from the dev server root.
|
||||
if (import.meta.env.DEV) {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
return `${origin}${BASE_PATH}/pdfium/pdfium.wasm`;
|
||||
}
|
||||
const cleanAssetUrl = pdfiumWasmAssetUrl
|
||||
.replace(/^\.\//, "")
|
||||
.replace(/^\//, "");
|
||||
return `${origin}${BASE_PATH}/${cleanAssetUrl}`;
|
||||
|
||||
// Vite has already produced a base-aware asset URL (absolute under a relative
|
||||
// base, root-relative under an absolute base). Resolve it against the document
|
||||
// to get a fetchable absolute URL that is also safe to pass to Web Workers.
|
||||
if (typeof window !== "undefined") {
|
||||
return new URL(pdfiumWasmAssetUrl, window.location.href).href;
|
||||
}
|
||||
return pdfiumWasmAssetUrl;
|
||||
};
|
||||
|
||||
export const pdfiumWasmUrl = getWasmUrl();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { installFailOnConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
installFailOnConsole();
|
||||
|
||||
// Mock localStorage for tests
|
||||
class LocalStorageMock implements Storage {
|
||||
|
||||
@@ -29,6 +29,8 @@ import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "@app/i18n/config";
|
||||
import { createTestStirlingFile } from "@app/tests/utils/testFileHelpers";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { StirlingFile } from "@app/types/fileContext";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
@@ -78,6 +80,7 @@ vi.mock("../../services/fileStorage", () => ({
|
||||
thumbnail: thumbnail,
|
||||
});
|
||||
}),
|
||||
storeStirlingFile: vi.fn().mockResolvedValue(undefined),
|
||||
getAllFileMetadata: vi.fn().mockResolvedValue([]),
|
||||
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
@@ -209,6 +212,10 @@ describe("Convert Tool Integration Tests", () => {
|
||||
expect(result.current.downloadFilename).toBe("test.png");
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
|
||||
// The output file must be persisted via fileStorage.storeStirlingFile
|
||||
// so downstream tools see it in the registry.
|
||||
expect(fileStorage.storeStirlingFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle API error responses correctly", async () => {
|
||||
@@ -571,6 +578,9 @@ describe("Convert Tool Integration Tests", () => {
|
||||
|
||||
describe("File Upload Integration", () => {
|
||||
test("should handle multiple file uploads correctly", async () => {
|
||||
// Test mocks only one apiClient.post response; the second file hits an
|
||||
// undefined response and production warns about the conversion failure.
|
||||
expectConsole.warn(/Failed to convert file test2\.pdf/);
|
||||
const mockBlob = new Blob(["zip-content"], { type: "application/zip" });
|
||||
(mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
createTestStirlingFile,
|
||||
createTestFilesWithId,
|
||||
} from "@app/tests/utils/testFileHelpers";
|
||||
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mock axios (for static methods like CancelToken, isCancel)
|
||||
@@ -76,6 +78,7 @@ vi.mock("../../services/fileStorage", () => ({
|
||||
thumbnail: thumbnail,
|
||||
});
|
||||
}),
|
||||
storeStirlingFile: vi.fn().mockResolvedValue(undefined),
|
||||
getAllFileMetadata: vi.fn().mockResolvedValue([]),
|
||||
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
@@ -169,6 +172,10 @@ describe("Convert Tool - Smart Detection Integration Tests", () => {
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
|
||||
// The output file must be persisted via fileStorage.storeStirlingFile
|
||||
// so downstream tools see it in the registry.
|
||||
expect(fileStorage.storeStirlingFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle unknown file type with file-to-pdf fallback", async () => {
|
||||
@@ -496,6 +503,12 @@ describe("Convert Tool - Smart Detection Integration Tests", () => {
|
||||
});
|
||||
|
||||
test("should send correct PDF/A parameters for pdf-to-pdfa conversion", async () => {
|
||||
// Test files aren't registered in the FileContext stub registry, so
|
||||
// production warns about the stub/output mismatch - incidental to what
|
||||
// this test asserts.
|
||||
allowConsole.warn(
|
||||
/\[useToolOperation\] Mismatch successInputStubs vs outputs/,
|
||||
);
|
||||
const { result: paramsResult } = renderHook(
|
||||
() => useConvertParameters(),
|
||||
{
|
||||
@@ -638,6 +651,9 @@ describe("Convert Tool - Smart Detection Integration Tests", () => {
|
||||
|
||||
describe("Error Scenarios in Smart Detection", () => {
|
||||
test("should handle partial failures in multi-file processing", async () => {
|
||||
// Production warns when an individual file in a batch fails; the test
|
||||
// deliberately drives one mock rejection to exercise that path.
|
||||
expectConsole.warn(/Failed to convert file doc2\.xyz/);
|
||||
const { result: paramsResult } = renderHook(
|
||||
() => useConvertParameters(),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowConsole,
|
||||
expectConsole,
|
||||
formatArgs,
|
||||
matches,
|
||||
} from "@app/tests/failOnConsole";
|
||||
|
||||
describe("failOnConsole", () => {
|
||||
describe("matches", () => {
|
||||
it("treats a string matcher as a substring test", () => {
|
||||
expect(matches("foo", "the foo bar")).toBe(true);
|
||||
expect(matches("foo", "no match here")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a RegExp matcher as a .test() call", () => {
|
||||
expect(matches(/^\[Tag\]/, "[Tag] something")).toBe(true);
|
||||
expect(matches(/^\[Tag\]/, "prefix [Tag] something")).toBe(false);
|
||||
});
|
||||
|
||||
it("string match is case-sensitive", () => {
|
||||
expect(matches("Foo", "foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatArgs", () => {
|
||||
it("joins string args with spaces", () => {
|
||||
expect(formatArgs(["one", "two", "three"])).toBe("one two three");
|
||||
});
|
||||
|
||||
it("stringifies plain objects with JSON", () => {
|
||||
expect(formatArgs([{ a: 1, b: "x" }])).toBe('{"a":1,"b":"x"}');
|
||||
});
|
||||
|
||||
it("uses the stack for Error instances when present", () => {
|
||||
const err = new Error("boom");
|
||||
const result = formatArgs(["context:", err]);
|
||||
expect(result).toContain("context:");
|
||||
expect(result).toContain("boom");
|
||||
});
|
||||
|
||||
it("falls back to String() when JSON.stringify throws (circular ref)", () => {
|
||||
const circular: Record<string, unknown> = { name: "loop" };
|
||||
circular.self = circular;
|
||||
const out = formatArgs([circular]);
|
||||
expect(out).toBe("[object Object]");
|
||||
});
|
||||
|
||||
it("handles non-string, non-Error primitives", () => {
|
||||
expect(formatArgs([42, true, null])).toBe("42 true null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expectConsole / allowConsole integration", () => {
|
||||
it("expectConsole.error absorbs a matching call and satisfies the expectation", () => {
|
||||
expectConsole.error(/expected sentinel/);
|
||||
console.error("an expected sentinel was emitted");
|
||||
// No assertion failure here means: the call was absorbed AND
|
||||
// afterEach won't throw because the expectation matched.
|
||||
});
|
||||
|
||||
it("allowConsole.warn absorbs a matching call without asserting it fires", () => {
|
||||
allowConsole.warn(/optional sentinel/);
|
||||
// Intentionally do NOT call console.warn. The test should still
|
||||
// pass because allowConsole is non-required.
|
||||
});
|
||||
|
||||
it("allowConsole.warn absorbs the call when it does fire too", () => {
|
||||
allowConsole.warn(/optional sentinel that does fire/);
|
||||
console.warn("optional sentinel that does fire now");
|
||||
});
|
||||
|
||||
it("supports a string matcher (substring)", () => {
|
||||
expectConsole.warn("substring sentinel inside");
|
||||
console.warn("a substring sentinel inside a longer message");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, vi, type MockInstance } from "vitest";
|
||||
|
||||
type ConsoleMethod = "error" | "warn";
|
||||
type Matcher = string | RegExp;
|
||||
|
||||
const WATCHED: ConsoleMethod[] = ["error", "warn"];
|
||||
|
||||
type Capture = { method: ConsoleMethod; args: unknown[] };
|
||||
|
||||
type Expectation = {
|
||||
method: ConsoleMethod;
|
||||
matcher: Matcher;
|
||||
/**
|
||||
* `true` for `expectConsole` (a contract: the test fails if no matching
|
||||
* call fires); `false` for `allowConsole` (incidental noise: absorbed if
|
||||
* it happens, ignored if it doesn't).
|
||||
*/
|
||||
required: boolean;
|
||||
matched: boolean;
|
||||
};
|
||||
|
||||
const captured: Capture[] = [];
|
||||
const expectations: Expectation[] = [];
|
||||
|
||||
/**
|
||||
* Vitest setup that fails any test whose code calls console.error or
|
||||
* console.warn. The goal is to keep the browser console clean during normal
|
||||
* usage of the app: React key warnings, act() warnings, deprecation
|
||||
* notices, and runtime errors should all surface as test failures rather
|
||||
* than silently scrolling past in CI.
|
||||
*
|
||||
* If a test legitimately needs to drive a log:
|
||||
* - Use `expectConsole.error(/pattern/)` when the log is part of the
|
||||
* contract under test. The matching call is absorbed AND the test
|
||||
* fails if it never fires, so the log can't silently disappear later.
|
||||
* - Use `allowConsole.warn(/pattern/)` when the log is incidental
|
||||
* (third-party noise, an artefact of a fake JWT, etc.). The matching
|
||||
* call is absorbed; nothing is asserted.
|
||||
*
|
||||
* Anything that doesn't match an expectation/allowance still fails the
|
||||
* test as before.
|
||||
*/
|
||||
export function installFailOnConsole(): void {
|
||||
const spies: MockInstance[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
captured.length = 0;
|
||||
expectations.length = 0;
|
||||
spies.length = 0;
|
||||
for (const method of WATCHED) {
|
||||
const original = console[method].bind(console);
|
||||
const spy = vi
|
||||
.spyOn(console, method)
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
const text = formatArgs(args);
|
||||
const match = expectations.find(
|
||||
(e) => e.method === method && matches(e.matcher, text),
|
||||
);
|
||||
if (match) {
|
||||
match.matched = true;
|
||||
return;
|
||||
}
|
||||
captured.push({ method, args });
|
||||
original(...args);
|
||||
});
|
||||
spies.push(spy);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const spy of spies.splice(0)) {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const unmet = expectations.filter((e) => e.required && !e.matched);
|
||||
const localCaptured = captured.splice(0);
|
||||
expectations.length = 0;
|
||||
|
||||
if (unmet.length > 0) {
|
||||
const lines = unmet
|
||||
.map((e) => ` console.${e.method}: ${formatMatcher(e.matcher)}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Expected console output never fired:\n${lines}\n\n` +
|
||||
"If production stopped logging this, either restore the log or " +
|
||||
"update/remove the expectConsole call.",
|
||||
);
|
||||
}
|
||||
if (localCaptured.length === 0) return;
|
||||
const lines = localCaptured
|
||||
.map((c) => ` console.${c.method}: ${formatArgs(c.args)}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Test produced unexpected console output:\n${lines}\n\n` +
|
||||
"If the log is expected, capture it with expectConsole.error/warn " +
|
||||
"(contract) or allowConsole.error/warn (incidental) from " +
|
||||
"@app/tests/failOnConsole.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a required console expectation. The test passes only if at
|
||||
* least one matching call fires; matching calls are absorbed instead of
|
||||
* failing the test.
|
||||
*/
|
||||
export const expectConsole = {
|
||||
error: (matcher: Matcher) => register("error", matcher, true),
|
||||
warn: (matcher: Matcher) => register("warn", matcher, true),
|
||||
};
|
||||
|
||||
/**
|
||||
* Register an optional console allowance. Matching calls are absorbed;
|
||||
* the test does NOT fail if no matching call fires. Use for incidental
|
||||
* noise (fake-JWT decode warnings, third-party library logs in dev, etc.).
|
||||
*/
|
||||
export const allowConsole = {
|
||||
error: (matcher: Matcher) => register("error", matcher, false),
|
||||
warn: (matcher: Matcher) => register("warn", matcher, false),
|
||||
};
|
||||
|
||||
function register(method: ConsoleMethod, matcher: Matcher, required: boolean) {
|
||||
expectations.push({ method, matcher, required, matched: false });
|
||||
}
|
||||
|
||||
/** Exported for the failOnConsole unit tests; not part of the public API. */
|
||||
export function matches(matcher: Matcher, text: string): boolean {
|
||||
return typeof matcher === "string"
|
||||
? text.includes(matcher)
|
||||
: matcher.test(text);
|
||||
}
|
||||
|
||||
function formatMatcher(matcher: Matcher): string {
|
||||
return matcher instanceof RegExp
|
||||
? matcher.toString()
|
||||
: JSON.stringify(matcher);
|
||||
}
|
||||
|
||||
/** Exported for the failOnConsole unit tests; not part of the public API. */
|
||||
export function formatArgs(args: unknown[]): string {
|
||||
return args.map(formatArg).join(" ");
|
||||
}
|
||||
|
||||
function formatArg(value: unknown): string {
|
||||
if (value instanceof Error) return value.stack ?? value.message;
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { errors, type ConsoleMessage, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke test: standard usage of the app must not produce any
|
||||
* console.error, console.warn, or uncaught page errors.
|
||||
*
|
||||
* Each test disables the fixture's auto-goto (via `test.use({ autoGoto:
|
||||
* false })`), attaches listeners inline with `attachListeners(page)` BEFORE
|
||||
* navigating, walks a representative route, lets it settle, then asserts
|
||||
* the captured buffer is empty.
|
||||
*
|
||||
* If you have a legitimate reason a warning fires on a given route
|
||||
* (third-party library noise we cannot influence, etc.), filter it via the
|
||||
* `IGNORED` allowlist below, but the default expectation is that the
|
||||
* console stays clean. Do not add entries casually; prefer fixing the
|
||||
* underlying issue.
|
||||
*/
|
||||
|
||||
type ConsoleEntry = {
|
||||
type: "error" | "warn" | "pageerror";
|
||||
text: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
const IGNORED: RegExp[] = [
|
||||
// Add entries here only with a comment explaining why the warning is
|
||||
// unavoidable. Default: keep this list empty.
|
||||
];
|
||||
|
||||
function shouldIgnore(text: string): boolean {
|
||||
return IGNORED.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
function attachListeners(page: Page): ConsoleEntry[] {
|
||||
const entries: ConsoleEntry[] = [];
|
||||
page.on("console", (msg: ConsoleMessage) => {
|
||||
const type = msg.type();
|
||||
if (type !== "error" && type !== "warning") return;
|
||||
const text = msg.text();
|
||||
if (shouldIgnore(text)) return;
|
||||
const loc = msg.location();
|
||||
entries.push({
|
||||
type: type === "warning" ? "warn" : "error",
|
||||
text,
|
||||
location: loc.url
|
||||
? `${loc.url}:${loc.lineNumber}:${loc.columnNumber}`
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (err) => {
|
||||
if (shouldIgnore(err.message)) return;
|
||||
entries.push({ type: "pageerror", text: err.stack ?? err.message });
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatEntries(entries: ConsoleEntry[]): string {
|
||||
return entries
|
||||
.map(
|
||||
(e) =>
|
||||
` [${e.type}] ${e.text}${e.location ? `\n at ${e.location}` : ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function expectCleanConsole(entries: ConsoleEntry[]) {
|
||||
expect(
|
||||
entries,
|
||||
`Page produced unexpected console output:\n${formatEntries(entries)}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
// ─── Routes to sweep ────────────────────────────────────────────────────────
|
||||
//
|
||||
// One entry per route we want to guarantee is console-clean on load. Mirrors
|
||||
// the most common user entry points; expand cautiously - every entry adds CI
|
||||
// time and triage surface for new warnings.
|
||||
|
||||
const ROUTES: { name: string; path: string }[] = [
|
||||
{ name: "landing", path: "/" },
|
||||
{ name: "files", path: "/files" },
|
||||
{ name: "compress", path: "/compress" },
|
||||
{ name: "split", path: "/split" },
|
||||
{ name: "merge", path: "/merge" },
|
||||
{ name: "convert", path: "/convert" },
|
||||
{ name: "rotate", path: "/rotate" },
|
||||
{ name: "addPageNumbers", path: "/add-page-numbers" },
|
||||
];
|
||||
|
||||
// Disable the fixture's auto-goto so we can attach listeners before any
|
||||
// navigation happens. Otherwise listeners miss early load-time noise.
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test.describe("Console hygiene: representative routes load cleanly", () => {
|
||||
for (const route of ROUTES) {
|
||||
test(`${route.name} (${route.path})`, async ({ page }) => {
|
||||
const entries = attachListeners(page);
|
||||
await page.goto(route.path, { waitUntil: "domcontentloaded" });
|
||||
// Give async effects (i18n load, lazy chunks, posthog init) a beat to
|
||||
// surface anything they were going to log.
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 10_000 })
|
||||
.catch((err) => {
|
||||
// networkidle can flake on third-party CDNs; treat ONLY the
|
||||
// timeout as benign and still run the console assertion on what
|
||||
// we captured. Anything else (frame detached, navigation abort,
|
||||
// etc.) is a real problem and should fail the test.
|
||||
if (!(err instanceof errors.TimeoutError)) throw err;
|
||||
});
|
||||
await expectCleanConsole(entries);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -486,12 +486,13 @@ test.describe("Files page screenshots", () => {
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
// Locate by stable test ids, not translated accessible names: this test
|
||||
// runs in Arabic (enableRtl), so English-text locators break once the
|
||||
// ar-AR strings are actually translated.
|
||||
await card.getByTestId("file-card-actions").click();
|
||||
await page.getByTestId("file-menu-move-to").click();
|
||||
await page.getByTestId("move-dialog-create-folder-toggle").click();
|
||||
await expect(page.getByTestId("move-dialog-new-folder-name")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("17_rtl_move_dialog_create_folder"),
|
||||
|
||||
@@ -121,6 +121,81 @@ test.describe("Settings dialog", () => {
|
||||
await restored.click();
|
||||
});
|
||||
|
||||
test("intra-modal tab switching updates URL via replaceState, never pushState", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Mechanism test for the "background flash" fix. Before the fix, every
|
||||
// tab click called `navigate(...)` which fired React Router's location
|
||||
// subscribers - HomePage, QuickAccessBar, FileManagerView, ... - and the
|
||||
// layer behind the Mantine overlay repainted, causing backdrop-filter
|
||||
// blur to recompute and visibly flash. After the fix, only the very
|
||||
// first nav into /settings/* is allowed to go through React Router (so
|
||||
// HomePage's location-watching effect opens the modal and the back
|
||||
// button has a real history entry to pop). Every subsequent tab click
|
||||
// updates the URL bar via raw `window.history.replaceState`, which
|
||||
// React Router does NOT subscribe to. We assert this directly by
|
||||
// counting calls.
|
||||
await page.addInitScript(() => {
|
||||
const w = window as unknown as {
|
||||
__historyOps: { push: number; replace: number };
|
||||
};
|
||||
w.__historyOps = { push: 0, replace: 0 };
|
||||
const origPush = window.history.pushState.bind(window.history);
|
||||
const origReplace = window.history.replaceState.bind(window.history);
|
||||
window.history.pushState = function (...args) {
|
||||
w.__historyOps.push++;
|
||||
return origPush(
|
||||
...(args as Parameters<typeof window.history.pushState>),
|
||||
);
|
||||
};
|
||||
window.history.replaceState = function (...args) {
|
||||
w.__historyOps.replace++;
|
||||
return origReplace(
|
||||
...(args as Parameters<typeof window.history.replaceState>),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openSettings(page);
|
||||
|
||||
const generalNav = page.locator('[data-tour="admin-general-nav"]').first();
|
||||
const hotkeysNav = page.locator('[data-tour="admin-hotkeys-nav"]').first();
|
||||
await expect(generalNav).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// First nav into /settings/* takes the React Router path (push). We
|
||||
// snapshot both counters AFTER this to isolate the intra-modal delta.
|
||||
await generalNav.click();
|
||||
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
|
||||
const baseline = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__historyOps: { push: number; replace: number };
|
||||
};
|
||||
return { ...w.__historyOps };
|
||||
});
|
||||
|
||||
// Now do 4 round-trips between two tabs - 8 intra-modal clicks total.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await hotkeysNav.click();
|
||||
await page.waitForURL(/\/settings\/hotkeys/, { timeout: 5_000 });
|
||||
await generalNav.click();
|
||||
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__historyOps: { push: number; replace: number };
|
||||
};
|
||||
return { ...w.__historyOps };
|
||||
});
|
||||
|
||||
// Zero pushes during 8 intra-modal clicks - the regression would show
|
||||
// up here as `after.push - baseline.push >= 1`.
|
||||
expect(after.push - baseline.push).toBe(0);
|
||||
// Exactly 8 replaces - one per click.
|
||||
expect(after.replace - baseline.replace).toBe(8);
|
||||
});
|
||||
|
||||
test("close returns to origin URL even after switching tabs (no history pile-up)", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js,
|
||||
* and the js.stripe.com remote script) is NOT fetched on cold page loads —
|
||||
* only when the checkout modal actually mounts. The proprietary
|
||||
* CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings
|
||||
* page all gate the modal behind React.lazy + a conditional render so the
|
||||
* Stripe chunk lives in its own async bundle.
|
||||
*/
|
||||
|
||||
const STRIPE_URL_FRAGMENTS = [
|
||||
"js.stripe.com",
|
||||
"@stripe/stripe-js",
|
||||
// Vite's optimizeDeps id mangles slashes to underscores when serving
|
||||
// pre-bundled dependencies from node_modules/.vite/deps.
|
||||
"@stripe_stripe-js",
|
||||
"@stripe/react-stripe-js",
|
||||
"@stripe_react-stripe-js",
|
||||
];
|
||||
|
||||
function isStripeRequest(url: string): boolean {
|
||||
return STRIPE_URL_FRAGMENTS.some((fragment) => url.includes(fragment));
|
||||
}
|
||||
|
||||
test.describe("Stripe SDK lazy loading", () => {
|
||||
test("landing page does not fetch Stripe SDK on cold load", async ({
|
||||
page,
|
||||
}) => {
|
||||
const stripeRequests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
const url = req.url();
|
||||
if (isStripeRequest(url)) {
|
||||
stripeRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// The stub fixture already navigated to "/" with waitUntil:
|
||||
// domcontentloaded. Wait for the app to actually settle so any lurking
|
||||
// module-eval-time loadStripe() call would have already kicked off.
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 15_000 })
|
||||
.catch(() => {
|
||||
// Posthog / iconify keep some connections warm — fall back to a
|
||||
// brief settle window if networkidle never resolves.
|
||||
});
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
expect(
|
||||
stripeRequests,
|
||||
`Expected no Stripe-related network activity on landing, but observed:\n${stripeRequests.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("settings modal does not fetch Stripe SDK", async ({ page }) => {
|
||||
const stripeRequests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
const url = req.url();
|
||||
if (isStripeRequest(url)) {
|
||||
stripeRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Opening Settings is the closest a default proprietary user gets to
|
||||
// checkout without actually clicking Upgrade. Even rendering the
|
||||
// settings drawer must NOT pull Stripe into the entry path — only the
|
||||
// upgrade modal itself, which sits one click further in.
|
||||
const settingsButton = page
|
||||
.getByRole("button", { name: /settings/i })
|
||||
.first();
|
||||
if (await settingsButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await settingsButton.click();
|
||||
await page.waitForTimeout(1_500);
|
||||
}
|
||||
|
||||
expect(
|
||||
stripeRequests,
|
||||
`Expected no Stripe-related network activity after opening settings, but observed:\n${stripeRequests.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
import {
|
||||
convertToAutomationConfig,
|
||||
convertToFolderScanningConfig,
|
||||
@@ -111,6 +112,7 @@ describe("automationConverter", () => {
|
||||
});
|
||||
|
||||
test("falls back to operation key when no endpoint is registered", () => {
|
||||
expectConsole.warn(/No endpoint found for operation "unknownTool"/);
|
||||
const automation: AutomationConfig = {
|
||||
...sampleAutomation,
|
||||
operations: [{ operation: "unknownTool", parameters: {} }],
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* injected via setScarfConfig() which should be called from a React hook
|
||||
* during app initialization.
|
||||
*
|
||||
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
|
||||
* The initialization hook (useScarfTracking) is mounted in App.tsx.
|
||||
* firePixel() can be called BEFORE setScarfConfig() runs: pre-config calls
|
||||
* are queued and replayed once setScarfConfig fires, so the initial-page-load
|
||||
* pixel from useUrlSync doesn't race the useScarfTracking init effect.
|
||||
*
|
||||
* For testing: Use resetScarfConfig() to clear module state between tests.
|
||||
*/
|
||||
@@ -19,6 +20,11 @@ let isServiceAccepted: ((service: string, category: string) => boolean) | null =
|
||||
null;
|
||||
let lastFiredPathname: string | null = null;
|
||||
let lastFiredTime = 0;
|
||||
// Pathnames passed to firePixel() before setScarfConfig() has run. Drained
|
||||
// once configured. Bounded to the most recent few entries since intermediate
|
||||
// path changes during a slow init are uninteresting.
|
||||
const pendingPaths: string[] = [];
|
||||
const PENDING_PATHS_CAP = 8;
|
||||
|
||||
/**
|
||||
* Configure scarf tracking with app config and consent checker
|
||||
@@ -34,6 +40,10 @@ export function setScarfConfig(
|
||||
configured = true;
|
||||
enableScarf = scarfEnabled;
|
||||
isServiceAccepted = consentChecker;
|
||||
// Drain anything queued before we were configured. Splice first so a
|
||||
// re-entrant firePixel from inside the drain can't re-queue.
|
||||
const queued = pendingPaths.splice(0);
|
||||
for (const path of queued) firePixel(path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,12 +57,10 @@ export function setScarfConfig(
|
||||
* @param pathname - The pathname to track (usually window.location.pathname)
|
||||
*/
|
||||
export function firePixel(pathname: string): void {
|
||||
// Dev-mode warning if called before initialization
|
||||
// Pre-init: queue and bail. setScarfConfig() drains.
|
||||
if (!configured) {
|
||||
console.warn(
|
||||
"[scarfTracking] firePixel() called before setScarfConfig(). " +
|
||||
"Ensure useScarfTracking() hook is mounted in App.tsx.",
|
||||
);
|
||||
if (pendingPaths.length >= PENDING_PATHS_CAP) pendingPaths.shift();
|
||||
pendingPaths.push(pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,8 +99,10 @@ export function firePixel(pathname: string): void {
|
||||
* Useful for testing to ensure clean state between test runs
|
||||
*/
|
||||
export function resetScarfConfig(): void {
|
||||
configured = false;
|
||||
enableScarf = null;
|
||||
isServiceAccepted = null;
|
||||
lastFiredPathname = null;
|
||||
lastFiredTime = 0;
|
||||
pendingPaths.length = 0;
|
||||
}
|
||||
|
||||
@@ -4,34 +4,16 @@ import { TFunction } from "i18next";
|
||||
export const getSynonyms = (t: TFunction, toolId: string): string[] => {
|
||||
try {
|
||||
const candidateKeys = [`home.${toolId}.tags`, `${toolId}.tags`];
|
||||
const match = candidateKeys
|
||||
const tags = candidateKeys
|
||||
.map((key) => ({ key, value: t(key) as unknown as string }))
|
||||
.find(({ key, value }) => value && value !== key);
|
||||
const tags = match?.value;
|
||||
const usedKey = match?.key;
|
||||
.find(({ key, value }) => value && value !== key)?.value;
|
||||
|
||||
if (!tags) {
|
||||
console.warn(`[Tags] Missing tags for tool: ${toolId}`);
|
||||
return [];
|
||||
}
|
||||
if (!tags) return [];
|
||||
|
||||
// Split by comma and clean up the tags
|
||||
const cleanedTags = tags
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag: string) => tag.trim())
|
||||
.filter((tag: string) => tag.length > 0);
|
||||
|
||||
// Log the tags found for this tool
|
||||
if (cleanedTags.length > 0) {
|
||||
console.info(
|
||||
`[Tags] Tool "${toolId}" (${usedKey}) has ${cleanedTags.length} tags:`,
|
||||
cleanedTags,
|
||||
);
|
||||
} else {
|
||||
console.warn(`[Tags] Tool "${toolId}" has empty tags value`);
|
||||
}
|
||||
|
||||
return cleanedTags;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Tags] Failed to get translated synonyms for tool ${toolId}:`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// ── Mocks (hoisted by vi.mock) ──────────────────────────────────────────────
|
||||
const invokeMock = vi.fn();
|
||||
@@ -46,13 +47,21 @@ async function flushMicrotasks() {
|
||||
|
||||
const AUTO_FAILURE_KEY = "stirling-pdf-updater:autoFailedAt";
|
||||
|
||||
/** Run the hook through its startup timer + async chain. */
|
||||
/**
|
||||
* Run the hook through its startup timer + async chain. The post-render
|
||||
* timer-driven async work is wrapped in act() so the state updates it
|
||||
* triggers don't surface as React "not wrapped in act" warnings.
|
||||
* renderHook already wraps the initial render in act internally, so it
|
||||
* stays outside.
|
||||
*/
|
||||
async function runStartup() {
|
||||
renderHook(() => useDesktopUpdatePopup());
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
await flushMicrotasks();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
await flushMicrotasks();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useDesktopUpdatePopup — auto mode", () => {
|
||||
@@ -83,6 +92,7 @@ describe("useDesktopUpdatePopup — auto mode", () => {
|
||||
});
|
||||
|
||||
it("does NOT call restart_app when download_and_install_update fails", async () => {
|
||||
expectConsole.error(/\[useDesktopInstall\] Install failed/);
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === "check_for_update") {
|
||||
return Promise.resolve({
|
||||
@@ -132,6 +142,9 @@ describe("useDesktopUpdatePopup — auto mode", () => {
|
||||
});
|
||||
|
||||
it("skips the install entirely when a recent failure is within the backoff window", async () => {
|
||||
expectConsole.warn(
|
||||
/\[DesktopUpdatePopup\] auto-update skipped: recent failure within backoff window/,
|
||||
);
|
||||
// Recorded 1 hour ago — well inside the 6-hour backoff.
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
window.localStorage.setItem(AUTO_FAILURE_KEY, String(oneHourAgo));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Exercise the error-interceptor logic against a hand-rolled axios-like
|
||||
// client; transitive imports are mocked out so this is a pure unit test.
|
||||
@@ -120,6 +121,7 @@ describe("desktop apiClientSetup - 401 silent-path", () => {
|
||||
});
|
||||
|
||||
test("POST 401 without Authorization dispatches sign-in modal event", async () => {
|
||||
expectConsole.warn(/\[apiClientSetup\] 401 on path/);
|
||||
const { client, handlers } = makeMockClient();
|
||||
setupApiInterceptors(client as unknown as AxiosInstance);
|
||||
await triggerErrorInterceptor(handlers, {
|
||||
@@ -135,6 +137,7 @@ describe("desktop apiClientSetup - 401 silent-path", () => {
|
||||
});
|
||||
|
||||
test("GET 401 without Authorization stays silent (background probe)", async () => {
|
||||
expectConsole.warn(/\[apiClientSetup\] 401 on path/);
|
||||
const { client, handlers } = makeMockClient();
|
||||
setupApiInterceptors(client as unknown as AxiosInstance);
|
||||
await triggerErrorInterceptor(handlers, {
|
||||
@@ -159,6 +162,7 @@ describe("desktop apiClientSetup - 401 silent-path", () => {
|
||||
});
|
||||
|
||||
test("401 with skipAuthRedirect set never dispatches the modal", async () => {
|
||||
expectConsole.warn(/\[apiClientSetup\] 401 on path/);
|
||||
const { client, handlers } = makeMockClient();
|
||||
setupApiInterceptors(client as unknown as AxiosInstance);
|
||||
await triggerErrorInterceptor(handlers, {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
|
||||
import {
|
||||
AxiosError,
|
||||
type AxiosResponse,
|
||||
@@ -89,9 +90,18 @@ describe("SpringAuthClient", () => {
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
// A 401 from /me triggers an explicit refresh attempt before treating
|
||||
// the session as invalid; lock that recovery contract in so future
|
||||
// refactors can't quietly skip it.
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/refresh",
|
||||
null,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 401 is handled gracefully, so error should be null
|
||||
@@ -117,9 +127,18 @@ describe("SpringAuthClient", () => {
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
// A 403 from /me triggers an explicit refresh attempt before treating
|
||||
// the session as invalid; lock that recovery contract in so future
|
||||
// refactors can't quietly skip it.
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/refresh",
|
||||
null,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 403 is handled gracefully, so error should be null
|
||||
@@ -129,6 +148,9 @@ describe("SpringAuthClient", () => {
|
||||
|
||||
describe("signInWithPassword", () => {
|
||||
it("should successfully sign in with email and password", async () => {
|
||||
// The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
|
||||
// about defaults - incidental to what this test verifies.
|
||||
allowConsole.warn(/Cannot decode token for adaptive intervals/);
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "password123",
|
||||
@@ -176,6 +198,7 @@ describe("SpringAuthClient", () => {
|
||||
});
|
||||
|
||||
it("should return error on failed login", async () => {
|
||||
expectConsole.error(/\[SpringAuth\] signInWithPassword error/);
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "wrongpassword",
|
||||
@@ -236,6 +259,7 @@ describe("SpringAuthClient", () => {
|
||||
});
|
||||
|
||||
it("should return error on failed registration", async () => {
|
||||
expectConsole.error(/\[SpringAuth\] signUp error/);
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "password123",
|
||||
@@ -283,6 +307,7 @@ describe("SpringAuthClient", () => {
|
||||
});
|
||||
|
||||
it("should clear JWT even if logout request fails", async () => {
|
||||
expectConsole.error(/\[SpringAuth\] signOut error/);
|
||||
const mockToken = "jwt-to-clear";
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
@@ -301,6 +326,9 @@ describe("SpringAuthClient", () => {
|
||||
|
||||
describe("refreshSession", () => {
|
||||
it("should refresh JWT token successfully", async () => {
|
||||
// The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
|
||||
// about defaults - incidental to what this test verifies.
|
||||
allowConsole.warn(/Cannot decode token for adaptive intervals/);
|
||||
const newToken = "refreshed-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
|
||||
@@ -397,22 +397,19 @@ class SpringAuthClient {
|
||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] getSession error:", error);
|
||||
|
||||
// If 401/403, token is invalid - try explicit refresh
|
||||
// 401/403 during getSession is the normal "token expired or invalid"
|
||||
// path - handled via refresh + JWT clear.
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
// A 401 during startup can be a race with a concurrent refresh. Try one
|
||||
// explicit refresh before treating the session as invalid.
|
||||
const refreshResult = await this.refreshSession();
|
||||
if (!refreshResult.error && refreshResult.data.session) {
|
||||
return refreshResult;
|
||||
}
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
console.debug("[SpringAuth] getSession: Not authenticated");
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
console.error("[SpringAuth] getSession error:", error);
|
||||
// Don't clear token for other errors (e.g., backend not ready, network issues)
|
||||
// The token is still valid, just can't verify it right now
|
||||
return {
|
||||
@@ -739,10 +736,11 @@ class SpringAuthClient {
|
||||
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] refreshSession error:", error);
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
|
||||
// Handle different error statuses
|
||||
// 401/403 means the refresh token is no longer valid - normal expired
|
||||
// state, not an error worth surfacing. Other statuses (network, backend
|
||||
// down) ARE worth logging.
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
@@ -751,6 +749,7 @@ class SpringAuthClient {
|
||||
};
|
||||
}
|
||||
|
||||
console.error("[SpringAuth] refreshSession error:", error);
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: getErrorMessage(error, "Token refresh failed") },
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function OverviewHeader() {
|
||||
const { t } = useTranslation();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate("/login");
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
window.location.assign("/login");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -71,10 +71,6 @@ export const useConfigNavSections = (
|
||||
"settings.tooltips.enableLoginFirst",
|
||||
"Enable login mode first",
|
||||
);
|
||||
const requiresEnterpriseTooltip = t(
|
||||
"settings.tooltips.requiresEnterprise",
|
||||
"Requires Enterprise license",
|
||||
);
|
||||
|
||||
// Workspace
|
||||
sections.push({
|
||||
@@ -199,10 +195,10 @@ export const useConfigNavSections = (
|
||||
label: t("settings.licensingAnalytics.audit", "Audit"),
|
||||
icon: "fact-check-rounded",
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? enableLoginTooltip
|
||||
: requiresEnterpriseTooltip,
|
||||
// Non-Enterprise users can still click in: AdminAuditSection
|
||||
// renders a demo preview when `!hasEnterpriseLicense`.
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
@@ -212,10 +208,9 @@ export const useConfigNavSections = (
|
||||
),
|
||||
icon: "analytics-rounded",
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? enableLoginTooltip
|
||||
: requiresEnterpriseTooltip,
|
||||
// Same demo-preview story as adminAudit above.
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -441,20 +436,22 @@ export const createConfigNavSections = (
|
||||
label: "Audit",
|
||||
icon: "fact-check-rounded",
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
// Non-Enterprise users can click in to see the demo preview.
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: "Requires Enterprise license",
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
label: "Usage Analytics",
|
||||
icon: "analytics-rounded",
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
// Non-Enterprise users can click in to see the demo preview.
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: "Requires Enterprise license",
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+5
-5
@@ -503,7 +503,7 @@ export default function AdminAdvancedSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.advanced.enableAlphaFunctionality.label",
|
||||
@@ -543,7 +543,7 @@ export default function AdminAdvancedSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.advanced.enableUrlToPDF.label",
|
||||
@@ -581,7 +581,7 @@ export default function AdminAdvancedSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.advanced.disableSanitize.label",
|
||||
@@ -959,7 +959,7 @@ export default function AdminAdvancedSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.advanced.tempFileManagement.startupCleanup.label",
|
||||
@@ -1002,7 +1002,7 @@ export default function AdminAdvancedSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label",
|
||||
|
||||
+2
-2
@@ -603,7 +603,7 @@ export default function AdminConnectionsSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.connections.ssoAutoLogin.enable",
|
||||
@@ -674,7 +674,7 @@ export default function AdminConnectionsSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.enable",
|
||||
|
||||
+1
-1
@@ -496,7 +496,7 @@ export default function AdminDatabaseSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.database.enableCustom.label",
|
||||
|
||||
+2
-2
@@ -200,7 +200,7 @@ export default function AdminFeaturesSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.enabled.label",
|
||||
@@ -316,7 +316,7 @@ export default function AdminFeaturesSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
|
||||
|
||||
+6
-6
@@ -703,7 +703,7 @@ export default function AdminGeneralSection() {
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.general.hideDisabledTools.googleDrive.label",
|
||||
@@ -747,7 +747,7 @@ export default function AdminGeneralSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.general.hideDisabledTools.mobileScanner.label",
|
||||
@@ -793,7 +793,7 @@ export default function AdminGeneralSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.general.showUpdate.label",
|
||||
@@ -832,7 +832,7 @@ export default function AdminGeneralSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.general.showUpdateOnlyAdmin.label",
|
||||
@@ -873,7 +873,7 @@ export default function AdminGeneralSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.general.customHTMLFiles.label",
|
||||
@@ -938,7 +938,7 @@ export default function AdminGeneralSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.general.customMetadata.autoUpdate.label",
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ export default function AdminPremiumSection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.premium.enabled.label",
|
||||
|
||||
+3
-3
@@ -165,7 +165,7 @@ export default function AdminPrivacySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.privacy.enableAnalytics.label",
|
||||
@@ -203,7 +203,7 @@ export default function AdminPrivacySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.privacy.metricsEnabled.label",
|
||||
@@ -253,7 +253,7 @@ export default function AdminPrivacySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.privacy.googleVisibility.label",
|
||||
|
||||
+14
-14
@@ -292,7 +292,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.enableLogin.label",
|
||||
@@ -519,7 +519,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.jwt.persistence.label",
|
||||
@@ -556,7 +556,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.jwt.enableKeyRotation.label",
|
||||
@@ -596,7 +596,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.jwt.enableKeyCleanup.label",
|
||||
@@ -780,7 +780,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.jwt.secureCookie.label",
|
||||
@@ -840,7 +840,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.audit.enabled.label",
|
||||
@@ -955,7 +955,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.audit.captureFileHash.label",
|
||||
@@ -995,7 +995,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.audit.capturePdfAuthor.label",
|
||||
@@ -1035,7 +1035,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.audit.captureOperationResults.label",
|
||||
@@ -1097,7 +1097,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.htmlUrlSecurity.enabled.label",
|
||||
@@ -1374,7 +1374,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label",
|
||||
@@ -1424,7 +1424,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.htmlUrlSecurity.blockLocalhost.label",
|
||||
@@ -1473,7 +1473,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.htmlUrlSecurity.blockLinkLocal.label",
|
||||
@@ -1522,7 +1522,7 @@ export default function AdminSecuritySection() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label",
|
||||
|
||||
+10
-3
@@ -585,9 +585,12 @@ export default function PeopleSection() {
|
||||
{t("workspace.people.user")}
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
fz="sm"
|
||||
w={100}
|
||||
>
|
||||
{t("workspace.people.role")}
|
||||
</Table.Th>
|
||||
@@ -690,7 +693,7 @@ export default function PeopleSection() {
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
@@ -699,6 +702,10 @@ export default function PeopleSection() {
|
||||
? "blue"
|
||||
: "cyan"
|
||||
}
|
||||
styles={{
|
||||
root: { maxWidth: "none" },
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
>
|
||||
{(user.rolesAsString || "").includes("ROLE_ADMIN")
|
||||
? t("workspace.people.admin", "Admin")
|
||||
|
||||
+9
-3
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, Text, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { PlanTier } from "@app/services/licenseService";
|
||||
|
||||
// Load Stripe once
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
interface PaymentStageProps {
|
||||
clientSecret: string | null;
|
||||
@@ -24,6 +22,14 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
|
||||
onPaymentComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Load Stripe.js lazily, only when PaymentStage mounts. Loading at module
|
||||
// scope pulled the Stripe script into every page (CheckoutContext is in
|
||||
// AppProviders), which triggered the dev "HTTPS required" warning on every
|
||||
// non-payment route.
|
||||
const stripePromise = useMemo(
|
||||
() => (STRIPE_KEY ? loadStripe(STRIPE_KEY) : null),
|
||||
[],
|
||||
);
|
||||
|
||||
// Show loading while creating checkout session
|
||||
if (!clientSecret || !selectedPlan) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
lazy,
|
||||
Suspense,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,7 +15,12 @@ import licenseService, {
|
||||
mapLicenseToTier,
|
||||
PlanTier,
|
||||
} from "@app/services/licenseService";
|
||||
import { StripeCheckout } from "@app/components/shared/stripeCheckout";
|
||||
|
||||
const StripeCheckout = lazy(() =>
|
||||
import("@app/components/shared/stripeCheckout").then((m) => ({
|
||||
default: m.StripeCheckout,
|
||||
})),
|
||||
);
|
||||
import { userManagementService } from "@app/services/userManagementService";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
@@ -433,16 +440,18 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
|
||||
{/* Global Checkout Modal */}
|
||||
{selectedPlanGroup && (
|
||||
<StripeCheckout
|
||||
opened={isOpen}
|
||||
onClose={closeCheckout}
|
||||
planGroup={selectedPlanGroup}
|
||||
minimumSeats={minimumSeats}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
onLicenseActivated={handleLicenseActivated}
|
||||
hostedCheckoutSuccess={hostedCheckoutSuccess}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<StripeCheckout
|
||||
opened={isOpen}
|
||||
onClose={closeCheckout}
|
||||
planGroup={selectedPlanGroup}
|
||||
minimumSeats={minimumSeats}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
onLicenseActivated={handleLicenseActivated}
|
||||
hostedCheckoutSuccess={hostedCheckoutSuccess}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</CheckoutContext.Provider>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Mock springAuth; keep the real redirect-path helpers.
|
||||
vi.mock("@app/auth/springAuthClient", async () => {
|
||||
@@ -90,6 +91,7 @@ describe("AuthCallback", () => {
|
||||
});
|
||||
|
||||
it("should redirect to login when no access token in hash", async () => {
|
||||
expectConsole.error(/\[AuthCallback\] No access_token in URL fragment/);
|
||||
// No hash or empty hash
|
||||
window.location.hash = "";
|
||||
|
||||
@@ -109,6 +111,7 @@ describe("AuthCallback", () => {
|
||||
});
|
||||
|
||||
it("should redirect to login when token validation fails", async () => {
|
||||
expectConsole.error(/\[AuthCallback\] Failed to validate token/);
|
||||
const invalidToken = "invalid-oauth-token";
|
||||
window.location.hash = `#access_token=${invalidToken}`;
|
||||
|
||||
@@ -137,6 +140,7 @@ describe("AuthCallback", () => {
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
expectConsole.error(/\[AuthCallback\] Authentication failed/);
|
||||
const mockToken = "error-token";
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
|
||||
@@ -18,42 +18,16 @@ export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const processingRef = useRef(false);
|
||||
|
||||
// Log component lifecycle
|
||||
useEffect(() => {
|
||||
const mountId = Math.random().toString(36).substring(7);
|
||||
console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`);
|
||||
return () => {
|
||||
console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`);
|
||||
};
|
||||
}, []);
|
||||
const startedAt = performance.now();
|
||||
const elapsed = () => `${(performance.now() - startedAt).toFixed(0)}ms`;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const startTime = performance.now();
|
||||
const executionId = Math.random().toString(36).substring(7);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Starting authentication callback`,
|
||||
);
|
||||
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Hash: ${window.location.hash}`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.sessionStorage.getItem("stirling_sso_auto_login_logged_out") ===
|
||||
"1"
|
||||
) {
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { error: "You have been signed out. Please sign in again." },
|
||||
@@ -62,30 +36,16 @@ export default function AuthCallback() {
|
||||
}
|
||||
|
||||
// Prevent double execution (React 18 Strict Mode + navigate dependency)
|
||||
if (processingRef.current) {
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`,
|
||||
);
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`,
|
||||
);
|
||||
|
||||
// Extract JWT from URL fragment (#access_token=...)
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get("access_token");
|
||||
const hash = window.location.hash.substring(1);
|
||||
const token = new URLSearchParams(hash).get("access_token");
|
||||
|
||||
if (!token) {
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
|
||||
`[AuthCallback] No access_token in URL fragment (${elapsed()})`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
@@ -94,39 +54,13 @@ export default function AuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`,
|
||||
);
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem("stirling_jwt", token);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`,
|
||||
);
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent("jwt-available"));
|
||||
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 4: Validating token with backend`,
|
||||
);
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (error || !data.session) {
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
|
||||
`[AuthCallback] Failed to validate token (${elapsed()}):`,
|
||||
error,
|
||||
);
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
@@ -137,68 +71,21 @@ export default function AuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`,
|
||||
);
|
||||
|
||||
await handleAuthCallbackSuccess(token);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Callback handlers complete`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`,
|
||||
);
|
||||
|
||||
// Wait for all context providers to process jwt-available event
|
||||
// This prevents infinite render loop when coming from cross-domain SAML redirect
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
const target = consumePostLoginRedirectPath() ?? "/";
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 7: Navigating to ${target}`,
|
||||
console.info(
|
||||
`[AuthCallback] Authenticated ${data.session.user.username} in ${elapsed()}, navigating to ${target}`,
|
||||
);
|
||||
navigate(target, { replace: true });
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`,
|
||||
);
|
||||
console.error(`[AuthCallback:${executionId}] Error:`, error);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error name:`,
|
||||
(error as Error)?.name,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error message:`,
|
||||
(error as Error)?.message,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error stack:`,
|
||||
(error as Error)?.stack,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
`[AuthCallback] Authentication failed (${elapsed()}):`,
|
||||
error,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter, MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
@@ -176,7 +176,7 @@ describe("Login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state while auth is loading", () => {
|
||||
it("should show loading state while auth is loading", async () => {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
session: null,
|
||||
user: null,
|
||||
@@ -187,13 +187,15 @@ describe("Login", () => {
|
||||
refreshSession: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
// Component shouldn't redirect or show form while loading
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
@@ -510,26 +512,30 @@ describe("Login", () => {
|
||||
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display session expired message from URL param", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?expired=true"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
it("should display session expired message from URL param", async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?expired=true"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display account created success message", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
it("should display account created success message", async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/account created/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -287,8 +287,6 @@ export default function Login() {
|
||||
setPostLoginRedirectPath(returnPath);
|
||||
}
|
||||
|
||||
console.log(`[Login] Signing in with provider: ${provider}`);
|
||||
|
||||
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
|
||||
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
@@ -520,8 +518,6 @@ export default function Login() {
|
||||
setError(null);
|
||||
clearLogoutBlock();
|
||||
|
||||
console.log("[Login] Signing in with email:", email);
|
||||
|
||||
const { user, session, error } = await springAuth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
@@ -529,13 +525,11 @@ export default function Login() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Login] Email sign in error:", error);
|
||||
setError(error.message);
|
||||
if (error.mfaRequired || error.code === "invalid_mfa_code") {
|
||||
setRequiresMfa(true);
|
||||
}
|
||||
} else if (user && session) {
|
||||
console.log("[Login] Email sign in successful");
|
||||
clearLogoutBlock();
|
||||
setRequiresMfa(false);
|
||||
setMfaCode("");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { TrialExpiredModal } from "@app/components/shared/TrialExpiredModal";
|
||||
import StripeCheckout from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
const StripeCheckout = lazy(
|
||||
() => import("@app/components/shared/StripeCheckoutSaas"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Bootstrap component that shows the trial expired modal when a user's trial has ended
|
||||
@@ -106,20 +109,22 @@ export default function TrialExpiredBootstrap() {
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
|
||||
{user && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpened}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) =>
|
||||
console.error("[TrialExpired] Checkout error:", error)
|
||||
}
|
||||
isTrialConversion={false} // Trial already ended, so this is not a conversion
|
||||
/>
|
||||
{user && checkoutOpened && (
|
||||
<Suspense fallback={null}>
|
||||
<StripeCheckout
|
||||
opened={checkoutOpened}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) =>
|
||||
console.error("[TrialExpired] Checkout error:", error)
|
||||
}
|
||||
isTrialConversion={false} // Trial already ended, so this is not a conversion
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { Z_INDEX_OVER_SETTINGS_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
export type PurchaseType = "subscription" | "credits";
|
||||
export type CreditsPack = "xsmall" | "small" | "medium" | "large" | null;
|
||||
@@ -68,6 +68,11 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: "idle" });
|
||||
// Load Stripe.js lazily, only when this checkout component mounts. Loading
|
||||
// at module scope pulled the Stripe script into every page that imports
|
||||
// this file, which triggered the dev "HTTPS required" warning on every
|
||||
// non-payment route.
|
||||
const stripePromise = useMemo(() => loadStripe(STRIPE_KEY), []);
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { lazy, Suspense, useEffect, useState, useCallback } from "react";
|
||||
import { useBanner } from "@app/contexts/BannerContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import StripeCheckout from "@app/components/shared/StripeCheckoutSaas";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
|
||||
const StripeCheckout = lazy(
|
||||
() => import("@app/components/shared/StripeCheckoutSaas"),
|
||||
);
|
||||
|
||||
const SESSION_STORAGE_KEY = "trialBannerDismissed";
|
||||
|
||||
export function TrialStatusBanner() {
|
||||
@@ -113,18 +116,20 @@ export function TrialStatusBanner() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{trialStatus && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen}
|
||||
onClose={() => setCheckoutOpen(false)}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error("Checkout error:", error)}
|
||||
isTrialConversion={true}
|
||||
/>
|
||||
{trialStatus && checkoutOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen}
|
||||
onClose={() => setCheckoutOpen(false)}
|
||||
purchaseType="subscription"
|
||||
planId="pro"
|
||||
creditsPack={null}
|
||||
planName="Pro"
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
onError={(error) => console.error("Checkout error:", error)}
|
||||
isTrialConversion={true}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { lazy, Suspense, useState, useCallback, useEffect } from "react";
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
|
||||
import { usePlans, PlanTier } from "@app/hooks/usePlans";
|
||||
import StripeCheckout, {
|
||||
import type {
|
||||
PurchaseType,
|
||||
CreditsPack,
|
||||
PlanID,
|
||||
} from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
const StripeCheckout = lazy(
|
||||
() => import("@app/components/shared/StripeCheckoutSaas"),
|
||||
);
|
||||
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
|
||||
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
|
||||
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
|
||||
@@ -204,29 +208,36 @@ const Plan: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
<StripeCheckout
|
||||
opened={
|
||||
checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null)
|
||||
}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={
|
||||
purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : null
|
||||
}
|
||||
creditsPack={purchaseType === "credits" ? selectedCreditsPack : null}
|
||||
planName={
|
||||
purchaseType === "subscription"
|
||||
? selectedPlan?.name || ""
|
||||
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)
|
||||
?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
{checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null) && (
|
||||
<Suspense fallback={null}>
|
||||
<StripeCheckout
|
||||
opened={true}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={
|
||||
purchaseType === "subscription"
|
||||
? (selectedPlan?.id as PlanID)
|
||||
: null
|
||||
}
|
||||
creditsPack={
|
||||
purchaseType === "credits" ? selectedCreditsPack : null
|
||||
}
|
||||
planName={
|
||||
purchaseType === "subscription"
|
||||
? selectedPlan?.name || ""
|
||||
: data?.apiPackages.find(
|
||||
(pkg) => pkg.id === selectedCreditsPack,
|
||||
)?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
|
||||
import type { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
interface ApiPackage {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, AuthError } from "@supabase/supabase-js";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Mock supabase
|
||||
vi.mock("@app/auth/supabase", () => ({
|
||||
@@ -177,6 +178,7 @@ describe("apiClient", () => {
|
||||
});
|
||||
|
||||
it("should handle refresh token failure", async () => {
|
||||
expectConsole.error(/\[API Client\] Token refresh failed/);
|
||||
const oldToken = "old-token";
|
||||
|
||||
const oldSession = {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { installFailOnConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
installFailOnConsole();
|
||||
|
||||
// Mock localStorage for tests
|
||||
const localStorageMock = (() => {
|
||||
|
||||
Reference in New Issue
Block a user