Add SaaS frontend code (#5879)

# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
This commit is contained in:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
@@ -0,0 +1,7 @@
/**
* Core stub for HomePage extensions.
*/
export function HomePageExtensions() {
return null;
}
@@ -28,6 +28,7 @@ import {
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
@@ -44,6 +45,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
const configButtonIcon = useConfigButtonIcon();
const {
tooltipOpen,
manualCloseOnly,
@@ -202,7 +205,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
...(shouldHideSettingsButton ? [] : [{
id: 'config',
name: t("quickAccess.settings", "Settings"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
icon: configButtonIcon ?? <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'md' as const,
type: 'modal' as const,
onClick: () => {
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections';
interface SettingsSearchBarProps {
configNavSections: ConfigNavSection[];
@@ -30,9 +30,11 @@ const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed";
interface GeneralSectionProps {
hideTitle?: boolean;
hideUpdateSection?: boolean;
hideAdminBanner?: boolean;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => {
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false }) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
@@ -156,7 +158,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</div>
)}
{loginDisabled && !bannerDismissed && (
{!hideAdminBanner && loginDisabled && !bannerDismissed && (
<Paper withBorder p="md" radius="md" style={{ background: "var(--mantine-color-blue-0)", position: "relative" }}>
<ActionIcon
variant="subtle"
@@ -215,7 +217,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
)}
{/* Update Check Section */}
{config?.appVersion && (
{!hideUpdateSection && config?.appVersion && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
+11 -25
View File
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCa
import apiClient from '@app/services/apiClient';
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
import type { AppConfig, AppConfigBootstrapMode } from '@app/types/appConfig';
import { useJwtConfigSync } from '@app/hooks/useJwtConfigSync';
/**
* Sleep utility for delays
@@ -41,6 +42,7 @@ export interface AppConfigProviderProps {
initialConfig?: AppConfig | null;
bootstrapMode?: AppConfigBootstrapMode;
autoFetch?: boolean;
onConfigLoaded?: (config: AppConfig) => void;
}
export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
@@ -49,6 +51,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
initialConfig = null,
bootstrapMode = 'blocking',
autoFetch = true,
onConfigLoaded,
}) => {
const isBlockingMode = bootstrapMode === 'blocking';
const [config, setConfig] = useState<AppConfig | null>(initialConfig);
@@ -58,6 +61,9 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const [hasResolvedConfig, setHasResolvedConfig] = useState(Boolean(initialConfig) && !isBlockingMode);
const [loading, setLoading] = useState(!hasResolvedConfig);
const onConfigLoadedRef = React.useRef(onConfigLoaded);
onConfigLoadedRef.current = onConfigLoaded;
const maxRetries = retryOptions?.maxRetries ?? 0;
const initialDelay = retryOptions?.initialDelay ?? 1000;
@@ -113,6 +119,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
setConfig(data);
setHasResolvedConfig(true);
setLoading(false);
onConfigLoadedRef.current?.(data);
return; // Success - exit function
} catch (err: any) {
const status = err?.response?.status;
@@ -151,42 +158,21 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
setLoading(false);
}, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
useEffect(() => {
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
// Config will be fetched after successful authentication via jwt-available event
const currentPath = window.location.pathname;
const isAuthPage = currentPath.includes('/login') ||
currentPath.includes('/signup') ||
currentPath.includes('/auth/callback') ||
currentPath.includes('/invite/');
const { isAuthPage } = useJwtConfigSync(fetchConfig);
// On auth pages, always skip the config fetch
// The config will be fetched after authentication via jwt-available event
useEffect(() => {
if (isAuthPage) {
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: currentPath });
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: window.location.pathname });
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
// On non-auth pages, fetch config (will validate JWT if present)
if (autoFetch) {
fetchConfig();
}
}, [autoFetch, fetchConfig]);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
// Force refetch with JWT
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [fetchConfig]);
}, [autoFetch, fetchConfig, isAuthPage]);
const refetch = useCallback(() => fetchConfig(true), [fetchConfig]);
@@ -121,13 +121,11 @@ export const useToolOperation = <TParams>(
? config.endpoint(params)
: config.endpoint;
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds)
if (willUseCloud && endpoint) {
const creditError = await checkCredits();
if (creditError !== null) {
actions.setError(creditError);
return;
}
// Credit check — no-op in core builds, real check in desktop/SaaS versions
const creditError = await checkCredits();
if (creditError !== null) {
actions.setError(creditError);
return;
}
// Backend readiness check (will skip for SaaS-routed endpoints)
@@ -0,0 +1,6 @@
/**
* Core stub — config button uses the default settings icon.
*/
export function useConfigButtonIcon(): React.ReactNode {
return null;
}
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* Core implementation: sets up the jwt-available event listener for OSS JWT auth,
* and detects auth pages where config fetch should be skipped.
*/
export function useJwtConfigSync(fetchConfig: (force?: boolean) => void): { isAuthPage: boolean } {
const currentPath = window.location.pathname;
const isAuthPage =
currentPath.includes('/login') ||
currentPath.includes('/signup') ||
currentPath.includes('/auth/callback') ||
currentPath.includes('/invite/');
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[AppConfig] JWT available event - refetching config');
fetchConfig(true);
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [fetchConfig]);
return { isAuthPage };
}
+2
View File
@@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
import "@app/pages/HomePage.css";
@@ -204,6 +205,7 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<HomePageExtensions />
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
+10
View File
@@ -13,3 +13,13 @@
@layer utilities {
@tailwind utilities;
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": [
"src/core/*"
]
}
},
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"."
]
}