Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into styling/V2/NovemberCleanups

This commit is contained in:
EthanHealy01 2025-11-14 13:08:09 +00:00
commit 9ac27f0b7c
58 changed files with 1475 additions and 459 deletions

74
.dockerignore Normal file
View File

@ -0,0 +1,74 @@
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.iml
*.iws
*.ipr
# Logs
*.log
logs
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml

View File

@ -505,10 +505,19 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
}
@Data

View File

@ -75,6 +75,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")

View File

@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
@ -20,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@Slf4j
public class ConfigController {
private final ApplicationProperties applicationProperties;
@ -59,9 +62,15 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();

View File

@ -176,6 +176,7 @@ system:
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints:

View File

@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
@Data
public static class LoginData {
private Boolean enableLogin;
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;

View File

@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout");
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {

View File

@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '1.4.0'
version = '2.0.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'

View File

@ -1,3 +1,6 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -3177,6 +3177,7 @@
"rememberme": "Remember me",
"invalid": "Invalid username or password.",
"locked": "Your account has been locked.",
"sessionExpired": "Your session has expired. Please sign in again.",
"signinTitle": "Please sign in",
"ssoSignIn": "Login via Single Sign-on",
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
@ -3682,6 +3683,12 @@
"saveSuccess": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Restart Required",
"loginRequired": "Login mode must be enabled to modify admin settings",
"loginDisabled": {
"title": "Login Mode Required",
"message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
"readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
},
"restart": {
"title": "Restart Required",
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
@ -3752,6 +3759,12 @@
"description": "Default producer for PDF metadata"
}
},
"logoStyle": {
"label": "Logo Style",
"description": "Choose between the modern minimalist logo or the classic S icon",
"classic": "Classic",
"modern": "Modern"
},
"customPaths": {
"label": "Custom Paths",
"description": "Configure custom file system paths for pipeline processing and external tools",

View File

@ -143,16 +143,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
<div
key={item.key}
onClick={() => {
if (!isDisabled) {
setActive(item.key);
navigate(`/settings/${item.key}`);
}
// Allow navigation even when disabled - the content inside will be disabled
setActive(item.key);
navigate(`/settings/${item.key}`);
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.6 : 1,
cursor: 'pointer',
}}
data-tour={`admin-${item.key}-nav`}
>

View File

@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
await accountService.changePassword(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword);
alert({
alertType: 'success',

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@ -14,6 +15,7 @@ const LandingPage = () => {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath();
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@ -72,7 +74,7 @@ const LandingPage = () => {
}}
>
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
src={logoPath}
alt="Stirling PDF Logo"
style={{
height: 'auto',

View File

@ -0,0 +1,38 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
interface LoginRequiredBannerProps {
show: boolean;
}
/**
* Banner component that displays when login mode is required but not enabled
* Shows prominent warning that settings are read-only
*/
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Alert
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
color="blue"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-blue-6)'
}
}}
>
<Text size="sm">
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
</Text>
<Text size="sm" fw={600} mt="xs" c="dimmed">
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
</Text>
</Alert>
);
}

View File

@ -2,18 +2,6 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
export interface ConfigNavItem {
key: NavKey;
@ -40,8 +28,8 @@ export interface ConfigColors {
}
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
_isAdmin: boolean = false,
_runningEE: boolean = false,
_loginEnabled: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
@ -64,112 +52,5 @@ export const createConfigNavSections = (
},
];
// Add Admin sections if user is admin
if (isAdmin) {
// Configuration
sections.push({
title: 'Configuration',
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
component: <AdminAuditSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
component: <AdminUsageSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
},
],
});
}
return sections;
};

View File

@ -10,6 +10,7 @@ interface ProviderCardProps {
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
disabled?: boolean;
}
export default function ProviderCard({
@ -18,6 +19,7 @@ export default function ProviderCard({
settings = {},
onSave,
onDisconnect,
disabled = false,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@ -39,6 +41,7 @@ export default function ProviderCard({
};
const handleFieldChange = (key: string, value: any) => {
if (disabled) return; // Block changes when disabled
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
@ -63,6 +66,7 @@ export default function ProviderCard({
<Switch
checked={value || false}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
disabled={disabled}
/>
</div>
);
@ -76,6 +80,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@ -88,6 +93,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@ -100,6 +106,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
}
@ -174,11 +181,12 @@ export default function ProviderCard({
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave}>
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import { useFocusTrap } from '@app/hooks/useFocusTrap';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { Tooltip } from '@app/components/shared/Tooltip';
import '@app/components/tools/ToolPanel.css';
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
@ -51,9 +52,7 @@ const FullscreenToolSurface = ({
useFocusTrap(surfaceRef, !isExiting);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;

View File

@ -19,6 +19,7 @@ export interface AppConfig {
serverPort?: number;
appNameNavbar?: string;
languages?: string[];
logoStyle?: 'modern' | 'classic';
enableLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;

View File

@ -0,0 +1,89 @@
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
/**
* Hook to manage login-required functionality in admin sections
* Provides login state, validation, and alert functionality
*/
export function useLoginRequired() {
const { config } = useAppConfig();
const { t } = useTranslation();
const loginEnabled = config?.enableLogin ?? true;
/**
* Show alert when user tries to modify settings with login disabled
*/
const showLoginRequiredAlert = () => {
alert({
alertType: 'warning',
title: t('admin.error', 'Error'),
body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'),
});
};
/**
* Validate that login is enabled before allowing action
* Returns true if login is enabled, false otherwise (and shows alert)
*/
const validateLoginEnabled = (): boolean => {
if (!loginEnabled) {
showLoginRequiredAlert();
return false;
}
return true;
};
/**
* Wrap an async handler to check login state before executing
*/
const withLoginCheck = <T extends (...args: any[]) => Promise<any>>(
handler: T
): T => {
return (async (...args: any[]) => {
if (!validateLoginEnabled()) {
return;
}
return handler(...args);
}) as T;
};
/**
* Get styles for disabled inputs (cursor not-allowed)
*/
const getDisabledStyles = () => {
if (!loginEnabled) {
return {
input: { cursor: 'not-allowed' },
track: { cursor: 'not-allowed' },
thumb: { cursor: 'not-allowed' }
};
}
return undefined;
};
/**
* Wrap fetch function to skip API call when login disabled
*/
const withLoginCheckForFetch = <T extends (...args: any[]) => Promise<any>>(
fetchHandler: T,
skipWhenDisabled: boolean = true
): T => {
return (async (...args: any[]) => {
if (!loginEnabled && skipWhenDisabled) {
// Skip fetch when login disabled - component will use default/empty values
return;
}
return fetchHandler(...args);
}) as T;
};
return {
loginEnabled,
showLoginRequiredAlert,
validateLoginEnabled,
withLoginCheck,
withLoginCheckForFetch,
getDisabledStyles,
};
}

View File

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useMantineColorScheme } from '@mantine/core';
import { BASE_PATH } from '@app/constants/app';
/**
* Hook to get the correct logo path based on app config (logo style) and theme (light/dark)
*
* Logo styles:
* - classic: branding/old/favicon.svg (classic S logo - default)
* - modern: StirlingPDFLogoNoText{Light|Dark}.svg (minimalist modern design)
*
* @returns The path to the appropriate logo SVG file
*/
export function useLogoPath(): string {
const { config } = useAppConfig();
const { colorScheme } = useMantineColorScheme();
return useMemo(() => {
const logoStyle = config?.logoStyle || 'classic';
if (logoStyle === 'classic') {
// Classic logo (old favicon) - same for both light and dark modes
return `${BASE_PATH}/branding/old/favicon.svg`;
}
// Modern logo - different for light and dark modes
const themeSuffix = colorScheme === 'dark' ? 'Dark' : 'Light';
return `${BASE_PATH}/branding/StirlingPDFLogoNoText${themeSuffix}.svg`;
}, [config?.logoStyle, colorScheme]);
}

View File

@ -8,6 +8,7 @@ import { BASE_PATH } from "@app/constants/app";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "@app/components/tools/ToolPanel";
@ -60,9 +61,7 @@ export default function HomePage() {
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;

View File

@ -31,4 +31,14 @@ export const accountService = {
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData);
},
/**
* Change user password on first login (resets firstLogin flag)
*/
async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData);
},
};

View File

@ -92,6 +92,32 @@ export async function handleHttpError(error: any): Promise<boolean> {
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Handle 401 authentication errors
const status: number | undefined = error?.response?.status;
if (status === 401) {
const pathname = window.location.pathname;
// Check if we're already on an auth page
const isAuthPage = pathname.includes('/login') ||
pathname.includes('/signup') ||
pathname.includes('/auth/') ||
pathname.includes('/invite/');
// If not on auth page, redirect to login with expired session message
if (!isAuthPage) {
console.debug('[httpErrorHandler] 401 detected, redirecting to login');
// Store the current location so we can redirect back after login
const currentLocation = window.location.pathname + window.location.search;
// Redirect to login with state
window.location.href = `/login?expired=true&from=${encodeURIComponent(currentLocation)}`;
return true; // Suppress toast since we're redirecting
}
// On auth pages, suppress the toast (user is already trying to authenticate)
console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname);
return true;
}
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
@ -112,7 +138,6 @@ export async function handleHttpError(error: any): Promise<boolean> {
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
const status: number | undefined = error?.response?.status;
const now = Date.now();
const isSpecial =
status === 422 ||

View File

@ -251,7 +251,7 @@ class SpringAuthClient {
* This redirects to the Spring OAuth2 authorization endpoint
*/
async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure';
provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {

View File

@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { BASE_PATH } from '@app/constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
export default function LoginRightCarousel({
function LoginRightCarousel({
imageSlides = [],
showBackground = true,
initialSeconds = 5,
@ -157,3 +157,5 @@ export default function LoginRightCarousel({
</div>
);
}
export default memo(LoginRightCarousel);

View File

@ -2,41 +2,181 @@ import React from 'react';
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
/**
* Proprietary extension of createConfigNavSections that adds workspace sections
* Proprietary extension of createConfigNavSections that adds all admin and workspace sections
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
): ConfigNavSection[] => {
// Get the core sections
const sections = createCoreConfigNavSections(isAdmin, runningEE);
// Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Workspace section if user is admin
if (isAdmin) {
const workspaceSection: ConfigNavSection = {
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
// Workspace
sections.push({
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
};
});
// Insert workspace section after Preferences (at index 1)
sections.splice(1, 0, workspaceSection);
// Configuration
sections.push({
title: 'Configuration',
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
}
// Add Developer section if login is enabled

View File

@ -7,6 +7,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
@ -55,6 +57,7 @@ interface AdvancedSettingsData {
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const {
settings,
@ -165,10 +168,15 @@ export default function AdminAdvancedSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -181,7 +189,9 @@ export default function AdminAdvancedSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -191,6 +201,7 @@ export default function AdminAdvancedSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed">
@ -213,7 +224,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.enableAlphaFunctionality || false}
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAlphaFunctionality: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
</Group>
@ -229,7 +245,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.enableUrlToPDF || false}
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableUrlToPDF: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableUrlToPDF')} />
</Group>
@ -245,7 +266,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, disableSanitize: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('disableSanitize')} />
</Group>
@ -271,6 +297,7 @@ export default function AdminAdvancedSection() {
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0}
max={3000}
disabled={!loginEnabled}
/>
</div>
@ -286,6 +313,7 @@ export default function AdminAdvancedSection() {
value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata"
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -311,6 +339,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
})}
placeholder="Default: java.io.tmpdir/stirling-pdf"
disabled={!loginEnabled}
/>
</div>
@ -324,6 +353,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
})}
placeholder="Default: baseTmpDir/libreoffice"
disabled={!loginEnabled}
/>
</div>
@ -337,6 +367,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
})}
placeholder="System temp directory path"
disabled={!loginEnabled}
/>
</div>
@ -350,6 +381,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
})}
placeholder="stirling-pdf-"
disabled={!loginEnabled}
/>
</div>
@ -364,6 +396,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={720}
disabled={!loginEnabled}
/>
</div>
@ -378,6 +411,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={1440}
disabled={!loginEnabled}
/>
</div>
@ -391,10 +425,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.startupCleanup ?? true}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
</Group>
@ -410,10 +449,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
</Group>
@ -448,6 +492,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -462,6 +507,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -485,6 +531,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -499,6 +546,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -522,6 +570,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -536,6 +585,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -559,6 +609,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -573,6 +624,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -596,6 +648,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -610,6 +663,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -633,6 +687,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -647,6 +702,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -670,6 +726,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -684,6 +741,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -707,6 +765,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -721,6 +780,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -744,6 +804,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -758,6 +819,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -781,6 +843,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -795,6 +858,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -805,7 +869,7 @@ export default function AdminAdvancedSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -6,9 +6,12 @@ import AuditSystemStatus from '@app/components/shared/config/configSections/audi
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -27,10 +30,24 @@ const AdminAuditSection: React.FC = () => {
}
};
fetchSystemStatus();
}, []);
if (loginEnabled) {
fetchSystemStatus();
} else {
// Provide example audit system status when login is disabled
setSystemStatus({
enabled: true,
level: 'INFO',
retentionDays: 90,
totalEvents: 1234,
});
setLoading(false);
}
}, [loginEnabled]);
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
@ -56,32 +73,33 @@ const AdminAuditSection: React.FC = () => {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<AuditSystemStatus status={systemStatus} />
{systemStatus.enabled ? (
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard">
<Tabs.Tab value="dashboard" disabled={!loginEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')}
</Tabs.Tab>
<Tabs.Tab value="events">
<Tabs.Tab value="events" disabled={!loginEnabled}>
{t('audit.tabs.events', 'Audit Events')}
</Tabs.Tab>
<Tabs.Tab value="export">
<Tabs.Tab value="export" disabled={!loginEnabled}>
{t('audit.tabs.export', 'Export')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="dashboard" pt="md">
<AuditChartsSection />
<AuditChartsSection loginEnabled={loginEnabled} />
</Tabs.Panel>
<Tabs.Panel value="events" pt="md">
<AuditEventsTable />
<AuditEventsTable loginEnabled={loginEnabled} />
</Tabs.Panel>
<Tabs.Panel value="export" pt="md">
<AuditExportSection />
<AuditExportSection loginEnabled={loginEnabled} />
</Tabs.Panel>
</Tabs>
) : (

View File

@ -12,6 +12,8 @@ import {
Provider,
} from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface ConnectionsSettingsData {
oauth2?: {
@ -45,15 +47,10 @@ interface ConnectionsSettingsData {
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = useAdminSettings<ConnectionsSettingsData>({
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
fetchTransformer: async () => {
// Fetch security settings (oauth2, saml2)
@ -106,57 +103,75 @@ export default function AdminConnectionsSection() {
}
});
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = adminSettings;
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
return settings.saml2?.enabled === true;
return settings?.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
return settings.mail?.enabled === true;
return settings?.mail?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings.oauth2?.enabled === true;
return settings?.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings.oauth2?.client?.[provider.id];
const providerSettings = settings?.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings.saml2 || {};
return settings?.saml2 || {};
}
if (provider.id === 'smtp') {
return settings.mail || {};
return settings?.mail || {};
}
if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level
return {
enabled: settings.oauth2?.enabled,
provider: settings.oauth2?.provider,
issuer: settings.oauth2?.issuer,
clientId: settings.oauth2?.clientId,
clientSecret: settings.oauth2?.clientSecret,
scopes: settings.oauth2?.scopes,
useAsUsername: settings.oauth2?.useAsUsername,
autoCreateUser: settings.oauth2?.autoCreateUser,
blockRegistration: settings.oauth2?.blockRegistration,
enabled: settings?.oauth2?.enabled,
provider: settings?.oauth2?.provider,
issuer: settings?.oauth2?.issuer,
clientId: settings?.oauth2?.clientId,
clientSecret: settings?.oauth2?.clientSecret,
scopes: settings?.oauth2?.scopes,
useAsUsername: settings?.oauth2?.useAsUsername,
autoCreateUser: settings?.oauth2?.autoCreateUser,
blockRegistration: settings?.oauth2?.blockRegistration,
};
}
// Specific OAuth2 provider settings
return settings.oauth2?.client?.[provider.id] || {};
return settings?.oauth2?.client?.[provider.id] || {};
};
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
@ -218,7 +233,12 @@ export default function AdminConnectionsSection() {
};
const handleProviderDisconnect = async (provider: Provider) => {
try {
// Block disconnect if login is disabled
if (!validateLoginEnabled()) {
return;
}
try{
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
@ -271,7 +291,7 @@ export default function AdminConnectionsSection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -280,9 +300,14 @@ export default function AdminConnectionsSection() {
}
const handleSSOAutoLoginSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
const deltaSettings = {
'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin
'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
@ -311,6 +336,8 @@ export default function AdminConnectionsSection() {
return (
<Stack gap="xl">
<LoginRequiredBanner show={!loginEnabled} />
{/* Header */}
<div>
<Text fw={600} size="lg">
@ -341,11 +368,14 @@ export default function AdminConnectionsSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.ssoAutoLogin || false}
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
handleSSOAutoLoginSave();
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
</Group>
@ -369,6 +399,7 @@ export default function AdminConnectionsSection() {
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
onDisconnect={() => handleProviderDisconnect(provider)}
disabled={!loginEnabled}
/>
))}
</Stack>
@ -392,6 +423,7 @@ export default function AdminConnectionsSection() {
provider={provider}
isConfigured={false}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
disabled={!loginEnabled}
/>
))}
</Stack>

View File

@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
interface DatabaseSettingsData {
@ -21,6 +23,7 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -78,10 +81,16 @@ export default function AdminDatabaseSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -94,7 +103,10 @@ export default function AdminDatabaseSection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -104,6 +116,8 @@ export default function AdminDatabaseSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group justify="space-between" align="center">
<div>
@ -130,14 +144,19 @@ export default function AdminDatabaseSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableCustomDatabase || false}
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
</Group>
</div>
{settings.enableCustomDatabase && (
{settings?.enableCustomDatabase && (
<>
<div>
<TextInput
@ -148,9 +167,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')}
value={settings.customDatabaseUrl || ''}
value={settings?.customDatabaseUrl || ''}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</div>
@ -163,7 +183,7 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
value={settings.type || 'postgresql'}
value={settings?.type || 'postgresql'}
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
data={[
{ value: 'postgresql', label: 'PostgreSQL' },
@ -171,6 +191,7 @@ export default function AdminDatabaseSection() {
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' }
]}
disabled={!loginEnabled}
/>
</div>
@ -183,9 +204,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
value={settings.hostName || ''}
value={settings?.hostName || ''}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
@ -198,10 +220,11 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
value={settings.port || 5432}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
@ -214,9 +237,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
value={settings.name || ''}
value={settings?.name || ''}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
@ -229,9 +253,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.username.description', 'Database authentication username')}
value={settings.username || ''}
value={settings?.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
@ -244,9 +269,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.password.description', 'Database authentication password')}
value={settings.password || ''}
value={settings?.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
disabled={!loginEnabled}
/>
</div>
</>
@ -256,7 +282,7 @@ export default function AdminDatabaseSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface EndpointsSettingsData {
toRemove?: string[];
@ -14,6 +16,7 @@ interface EndpointsSettingsData {
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -29,10 +32,16 @@ export default function AdminEndpointsSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -45,7 +54,10 @@ export default function AdminEndpointsSection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -102,6 +114,8 @@ export default function AdminEndpointsSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
@ -123,12 +137,16 @@ export default function AdminEndpointsSection() {
}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => setSettings({ ...settings, toRemove: value })}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -142,12 +160,16 @@ export default function AdminEndpointsSection() {
}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -160,7 +182,7 @@ export default function AdminEndpointsSection() {
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface FeaturesSettingsData {
serverCertificate?: {
@ -19,6 +21,7 @@ interface FeaturesSettingsData {
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -69,10 +72,15 @@ export default function AdminFeaturesSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -85,7 +93,9 @@ export default function AdminFeaturesSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -95,6 +105,7 @@ export default function AdminFeaturesSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
<Text size="sm" c="dimmed">
@ -124,10 +135,15 @@ export default function AdminFeaturesSection() {
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
</Group>
@ -148,6 +164,7 @@ export default function AdminFeaturesSection() {
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
@ -167,6 +184,7 @@ export default function AdminFeaturesSection() {
})}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
@ -180,10 +198,15 @@ export default function AdminFeaturesSection() {
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
</Group>
@ -193,7 +216,7 @@ export default function AdminFeaturesSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -1,17 +1,20 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge } from '@mantine/core';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface GeneralSettingsData {
ui: {
appNameNavbar?: string;
languages?: string[];
logoStyle?: 'modern' | 'classic';
};
system: {
defaultLocale?: string;
@ -40,6 +43,7 @@ interface GeneralSettingsData {
export default function AdminGeneralSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -108,14 +112,15 @@ export default function AdminGeneralSection() {
saveTransformer: (settings) => {
const deltaSettings: Record<string, any> = {
// UI settings
'ui.appNameNavbar': settings.ui.appNameNavbar,
'ui.languages': settings.ui.languages,
'ui.appNameNavbar': settings.ui?.appNameNavbar,
'ui.languages': settings.ui?.languages,
'ui.logoStyle': settings.ui?.logoStyle,
// System settings
'system.defaultLocale': settings.system.defaultLocale,
'system.showUpdate': settings.system.showUpdate,
'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system.customHTMLFiles,
'system.fileUploadLimit': settings.system.fileUploadLimit,
'system.defaultLocale': settings.system?.defaultLocale,
'system.showUpdate': settings.system?.showUpdate,
'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system?.customHTMLFiles,
'system.fileUploadLimit': settings.system?.fileUploadLimit,
// Premium custom metadata
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
@ -124,10 +129,10 @@ export default function AdminGeneralSection() {
};
if (settings.customPaths) {
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths.pipeline?.watchedFoldersDir;
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths.pipeline?.finishedFoldersDir;
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths.operations?.weasyprint;
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths.operations?.unoconvert;
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths?.pipeline?.watchedFoldersDir;
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths?.pipeline?.finishedFoldersDir;
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths?.operations?.weasyprint;
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths?.operations?.unoconvert;
}
return {
@ -138,10 +143,21 @@ export default function AdminGeneralSection() {
});
useEffect(() => {
fetchSettings();
}, []);
// Only fetch real settings if login is enabled
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -154,7 +170,7 @@ export default function AdminGeneralSection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -164,6 +180,8 @@ export default function AdminGeneralSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
<Text size="sm" c="dimmed">
@ -185,9 +203,55 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
value={settings.ui.appNameNavbar || ''}
value={settings.ui?.appNameNavbar || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
placeholder="Stirling PDF"
disabled={!loginEnabled}
/>
</div>
<div>
<Text size="sm" fw={500} mb={4}>
<Group gap="xs">
<span>{t('admin.settings.general.logoStyle.label', 'Logo Style')}</span>
<PendingBadge show={isFieldPending('ui.logoStyle')} />
</Group>
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t('admin.settings.general.logoStyle.description', 'Choose between the modern minimalist logo or the classic S icon')}
</Text>
<SegmentedControl
value={settings.ui?.logoStyle || 'classic'}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, logoStyle: value as 'modern' | 'classic' } })}
data={[
{
value: 'classic',
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0' }}>
<img
src="/branding/old/favicon.svg"
alt="Classic logo"
style={{ width: '24px', height: '24px' }}
/>
<span>{t('admin.settings.general.logoStyle.classic', 'Classic')}</span>
</div>
)
},
{
value: 'modern',
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0' }}>
<img
src="/branding/StirlingPDFLogoNoTextLight.svg"
alt="Modern logo"
style={{ width: '24px', height: '24px' }}
/>
<span>{t('admin.settings.general.logoStyle.modern', 'Modern')}</span>
</div>
)
},
]}
disabled={!loginEnabled}
/>
</div>
@ -200,7 +264,7 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
value={settings.ui.languages || []}
value={settings.ui?.languages || []}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
data={[
{ value: 'de_DE', label: 'Deutsch' },
@ -218,6 +282,7 @@ export default function AdminGeneralSection() {
clearable
placeholder="Select languages"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -230,9 +295,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
value={settings.system.defaultLocale || ''}
value={ settings.system?.defaultLocale || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
placeholder="en_US"
disabled={!loginEnabled}
/>
</div>
@ -245,9 +311,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
value={settings.system.fileUploadLimit || ''}
value={ settings.system?.fileUploadLimit || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
placeholder="100MB"
disabled={!loginEnabled}
/>
</div>
@ -260,8 +327,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdate || false}
checked={ settings.system?.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.showUpdate')} />
</Group>
@ -276,8 +344,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
checked={ settings.system?.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.showUpdateOnlyAdmin')} />
</Group>
@ -292,8 +361,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.customHTMLFiles || false}
checked={settings.system?.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.customHTMLFiles')} />
</Group>
@ -326,6 +396,7 @@ export default function AdminGeneralSection() {
autoUpdateMetadata: e.target.checked
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('customMetadata.autoUpdateMetadata')} />
</Group>
@ -349,6 +420,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="username"
disabled={!loginEnabled}
/>
</div>
@ -370,6 +442,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
@ -391,6 +464,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -429,6 +503,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/pipeline/watchedFolders"
disabled={!loginEnabled}
/>
</div>
@ -453,6 +528,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/pipeline/finishedFolders"
disabled={!loginEnabled}
/>
</div>
@ -479,6 +555,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/opt/venv/bin/weasyprint"
disabled={!loginEnabled}
/>
</div>
@ -503,6 +580,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/opt/venv/bin/unoconvert"
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -510,7 +588,7 @@ export default function AdminGeneralSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface LegalSettingsData {
termsAndConditions?: string;
@ -18,6 +20,7 @@ interface LegalSettingsData {
export default function AdminLegalSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -33,10 +36,15 @@ export default function AdminLegalSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -49,7 +57,9 @@ export default function AdminLegalSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -59,6 +69,7 @@ export default function AdminLegalSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
@ -95,6 +106,7 @@ export default function AdminLegalSection() {
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
@ -110,6 +122,7 @@ export default function AdminLegalSection() {
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
@ -125,6 +138,7 @@ export default function AdminLegalSection() {
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
@ -140,6 +154,7 @@ export default function AdminLegalSection() {
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
@ -155,13 +170,14 @@ export default function AdminLegalSection() {
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface PremiumSettingsData {
key?: string;
@ -15,6 +17,7 @@ interface PremiumSettingsData {
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -30,10 +33,15 @@ export default function AdminPremiumSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -46,7 +54,9 @@ export default function AdminPremiumSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -56,6 +66,7 @@ export default function AdminPremiumSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
@ -98,6 +109,7 @@ export default function AdminPremiumSection() {
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
/>
</div>
@ -111,7 +123,12 @@ export default function AdminPremiumSection() {
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
@ -120,7 +137,7 @@ export default function AdminPremiumSection() {
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
interface PrivacySettingsData {
@ -16,6 +18,7 @@ interface PrivacySettingsData {
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -76,10 +79,16 @@ export default function AdminPrivacySection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -92,7 +101,10 @@ export default function AdminPrivacySection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -102,6 +114,8 @@ export default function AdminPrivacySection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
@ -123,8 +137,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableAnalytics || false}
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAnalytics: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAnalytics')} />
</Group>
@ -139,8 +158,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, metricsEnabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('metricsEnabled')} />
</Group>
@ -162,8 +186,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, googleVisibility: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('googleVisibility')} />
</Group>
@ -173,7 +202,7 @@ export default function AdminPrivacySection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -8,6 +8,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface SecuritySettingsData {
enableLogin?: boolean;
@ -44,6 +46,7 @@ interface SecuritySettingsData {
export default function AdminSecuritySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -157,10 +160,20 @@ export default function AdminSecuritySection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -173,7 +186,7 @@ export default function AdminSecuritySection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -183,6 +196,8 @@ export default function AdminSecuritySection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
<Text size="sm" c="dimmed">
@ -204,8 +219,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableLogin || false}
checked={settings?.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('enableLogin')} />
</Group>
@ -215,7 +231,7 @@ export default function AdminSecuritySection() {
<Select
label={t('admin.settings.security.loginMethod.label', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
value={settings?.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
data={[
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
@ -224,6 +240,7 @@ export default function AdminSecuritySection() {
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
]}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
{isFieldPending('loginMethod') && (
<Group mt="xs">
@ -241,10 +258,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
value={settings?.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
min={0}
max={100}
disabled={!loginEnabled}
/>
</div>
@ -257,10 +275,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
value={settings?.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
min={0}
max={1440}
disabled={!loginEnabled}
/>
</div>
@ -273,8 +292,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.csrfDisabled || false}
checked={settings?.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('csrfDisabled')} />
</Group>
@ -308,8 +328,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
checked={settings?.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, persistence: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.persistence')} />
</Group>
@ -324,8 +345,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
checked={settings?.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, enableKeyRotation: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
</Group>
@ -340,8 +362,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
checked={settings?.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, enableKeyCleanup: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
</Group>
@ -356,10 +379,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
value={settings?.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
min={1}
max={365}
disabled={!loginEnabled}
/>
</div>
@ -372,8 +396,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
checked={settings?.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, secureCookie: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.secureCookie')} />
</Group>
@ -398,8 +423,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
checked={settings?.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, enabled: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('audit.enabled')} />
</Group>
@ -414,10 +440,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
value={settings?.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings?.audit, level: Number(value) } })}
min={0}
max={3}
disabled={!loginEnabled}
/>
</div>
@ -430,10 +457,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
value={settings?.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings?.audit, retentionDays: Number(value) } })}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -458,14 +486,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.enabled || false}
checked={settings?.html?.urlSecurity?.enabled || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, enabled: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.enabled')} />
</Group>
@ -480,12 +509,12 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
value={settings?.html?.urlSecurity?.level || 'MEDIUM'}
onChange={(value) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, level: value || 'MEDIUM' }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, level: value || 'MEDIUM' }
}
})}
data={[
@ -494,6 +523,7 @@ export default function AdminSecuritySection() {
{ value: 'OFF', label: t('admin.settings.security.htmlUrlSecurity.level.off', 'Off (No Restrictions)') },
]}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -512,13 +542,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
value={settings?.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
allowedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@ -526,6 +556,7 @@ export default function AdminSecuritySection() {
placeholder="cdn.example.com&#10;images.google.com"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@ -539,13 +570,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
value={settings?.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
blockedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@ -553,6 +584,7 @@ export default function AdminSecuritySection() {
placeholder="malicious.com&#10;evil.org"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@ -566,13 +598,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
value={settings?.html?.urlSecurity?.internalTlds?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
internalTlds: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@ -580,6 +612,7 @@ export default function AdminSecuritySection() {
placeholder=".local&#10;.internal&#10;.corp&#10;.home"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@ -595,14 +628,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
checked={settings?.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
</Group>
@ -617,14 +651,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
checked={settings?.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
</Group>
@ -639,14 +674,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
checked={settings?.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
</Group>
@ -661,14 +697,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
checked={settings?.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
</Group>
@ -682,7 +719,7 @@ export default function AdminSecuritySection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -14,9 +14,12 @@ import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -24,6 +27,10 @@ const AdminUsageSection: React.FC = () => {
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const fetchData = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
setLoading(true);
setError(null);
@ -40,10 +47,55 @@ const AdminUsageSection: React.FC = () => {
};
useEffect(() => {
fetchData();
}, [displayMode, dataType]);
if (loginEnabled) {
fetchData();
} else {
// Provide example usage analytics data when login is disabled
const totalVisits = 15847;
const allEndpoints = [
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
{ endpoint: 'compress-pdf', visits: 2891, percentage: (2891 / totalVisits) * 100 },
{ endpoint: 'pdf-to-img', visits: 2156, percentage: (2156 / totalVisits) * 100 },
{ endpoint: 'split-pdf', visits: 1834, percentage: (1834 / totalVisits) * 100 },
{ endpoint: 'rotate-pdf', visits: 1523, percentage: (1523 / totalVisits) * 100 },
{ endpoint: 'ocr-pdf', visits: 1287, percentage: (1287 / totalVisits) * 100 },
{ endpoint: 'add-watermark', visits: 945, percentage: (945 / totalVisits) * 100 },
{ endpoint: 'extract-images', visits: 782, percentage: (782 / totalVisits) * 100 },
{ endpoint: 'add-password', visits: 621, percentage: (621 / totalVisits) * 100 },
{ endpoint: 'html-to-pdf', visits: 563, percentage: (563 / totalVisits) * 100 },
{ endpoint: 'remove-password', visits: 487, percentage: (487 / totalVisits) * 100 },
{ endpoint: 'pdf-to-pdfa', visits: 423, percentage: (423 / totalVisits) * 100 },
{ endpoint: 'extract-pdf-metadata', visits: 356, percentage: (356 / totalVisits) * 100 },
{ endpoint: 'add-page-numbers', visits: 298, percentage: (298 / totalVisits) * 100 },
{ endpoint: 'crop', visits: 245, percentage: (245 / totalVisits) * 100 },
{ endpoint: 'flatten', visits: 187, percentage: (187 / totalVisits) * 100 },
{ endpoint: 'sanitize-pdf', visits: 134, percentage: (134 / totalVisits) * 100 },
{ endpoint: 'auto-split-pdf', visits: 98, percentage: (98 / totalVisits) * 100 },
{ endpoint: 'scale-pages', visits: 76, percentage: (76 / totalVisits) * 100 },
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
];
// Filter based on display mode
let filteredEndpoints = allEndpoints;
if (displayMode === 'top10') {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === 'top20') {
filteredEndpoints = allEndpoints.slice(0, 20);
}
setData({
totalVisits: totalVisits,
totalEndpoints: filteredEndpoints.length,
endpoints: filteredEndpoints,
});
setLoading(false);
}
}, [displayMode, dataType, loginEnabled]);
const handleRefresh = () => {
if (!validateLoginEnabled()) {
return;
}
fetchData();
};
@ -60,8 +112,11 @@ const AdminUsageSection: React.FC = () => {
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
// Early returns for loading/error states
if (loading) {
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<Loader size="lg" />
@ -85,16 +140,18 @@ const AdminUsageSection: React.FC = () => {
);
}
const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0);
const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
const displayedPercentage = data.totalVisits > 0
? ((displayedVisits / data.totalVisits) * 100).toFixed(1)
const displayedPercentage = (data?.totalVisits || 0) > 0
? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
: '0';
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
{/* Controls */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
@ -103,6 +160,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl
value={displayMode}
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
disabled={!loginEnabled}
data={[
{
value: 'top10',
@ -123,6 +181,7 @@ const AdminUsageSection: React.FC = () => {
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={handleRefresh}
loading={loading}
disabled={!loginEnabled}
>
{t('usage.controls.refresh', 'Refresh')}
</Button>
@ -136,6 +195,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
disabled={!loginEnabled}
data={[
{
value: 'all',

View File

@ -29,10 +29,13 @@ import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
@ -71,30 +74,103 @@ export default function PeopleSection() {
const fetchData = async () => {
try {
setLoading(true);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
if (loginEnabled) {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setUsers(enrichedUsers);
setTeams(teamsData);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
// Store license information
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
setUsers(enrichedUsers);
setTeams(teamsData);
// Store license information
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
} else {
// Provide example data when login is disabled
const exampleUsers: User[] = [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
enabled: true,
roleName: 'ROLE_ADMIN',
rolesAsString: 'ROLE_ADMIN',
authenticationType: 'password',
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: 'Engineering' }
},
{
id: 2,
username: 'john.doe',
email: 'john.doe@example.com',
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: 'Engineering' }
},
{
id: 3,
username: 'jane.smith',
email: 'jane.smith@example.com',
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'oauth',
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: 'Marketing' }
},
{
id: 4,
username: 'bob.wilson',
email: 'bob.wilson@example.com',
enabled: false,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined
}
];
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 }
];
setUsers(exampleUsers);
setTeams(exampleTeams);
// Example license information
setLicenseInfo({
maxAllowedUsers: 10,
availableSlots: 6,
grandfatheredUserCount: 0,
licenseMaxUsers: 5,
premiumEnabled: true,
totalUsers: 4,
});
}
} catch (error) {
console.error('Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
@ -230,6 +306,7 @@ export default function PeopleSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
@ -282,15 +359,15 @@ export default function PeopleSection() {
style={{ maxWidth: 300 }}
/>
<Tooltip
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
label={!loginEnabled ? 'Enable login mode first' : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
position="bottom"
withArrow
>
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setInviteModalOpened(true)}
disabled={licenseInfo ? licenseInfo.availableSlots === 0 : false}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
</Button>
@ -441,20 +518,21 @@ export default function PeopleSection() {
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item onClick={() => openEditModal(user)}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item onClick={() => openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
onClick={() => handleToggleEnabled(user)}
disabled={!loginEnabled}
>
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)}>
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)} disabled={!loginEnabled}>
{t('workspace.people.deleteUser')}
</Menu.Item>
</Menu.Dropdown>

View File

@ -22,9 +22,12 @@ import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
export default function TeamsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
@ -47,8 +50,18 @@ export default function TeamsSection() {
const fetchTeams = async () => {
try {
setLoading(true);
const teamsData = await teamService.getTeams();
setTeams(teamsData);
if (loginEnabled) {
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 },
{ id: 3, name: 'Internal', userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' });
@ -207,6 +220,7 @@ export default function TeamsSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
@ -218,7 +232,7 @@ export default function TeamsSection() {
{/* Header Actions */}
<Group justify="flex-end">
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)}>
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)} disabled={!loginEnabled}>
{t('workspace.teams.createNewTeam')}
</Button>
</Group>
@ -257,8 +271,8 @@ export default function TeamsSection() {
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: 'pointer' }}
onClick={() => setViewingTeamId(team.id)}
style={{ cursor: loginEnabled ? 'pointer' : 'default' }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
@ -290,18 +304,18 @@ export default function TeamsSection() {
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)} disabled={!loginEnabled}>
{t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)}>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.addMember')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)}>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.renameTeamLabel')}
</Menu.Item>
<Menu.Divider />
@ -309,7 +323,7 @@ export default function TeamsSection() {
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={team.name === 'Internal'}
disabled={!loginEnabled || team.name === 'Internal'}
>
{t('workspace.teams.deleteTeamLabel')}
</Menu.Item>

View File

@ -53,7 +53,11 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'b
);
};
const AuditChartsSection: React.FC = () => {
interface AuditChartsSectionProps {
loginEnabled?: boolean;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
@ -74,8 +78,27 @@ const AuditChartsSection: React.FC = () => {
}
};
fetchChartsData();
}, [timePeriod]);
if (loginEnabled) {
fetchChartsData();
} else {
// Provide example charts data when login is disabled
setChartsData({
eventsByType: {
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
labels: ['admin', 'user1', 'user2', 'user3', 'user4'],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
@ -123,7 +146,11 @@ const AuditChartsSection: React.FC = () => {
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')}
onChange={(value) => {
if (!loginEnabled) return;
setTimePeriod(value as 'day' | 'week' | 'month');
}}
disabled={!loginEnabled}
data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' },

View File

@ -18,7 +18,11 @@ import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditEventsTable: React.FC = () => {
interface AuditEventsTableProps {
loginEnabled?: boolean;
}
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0);
@ -51,8 +55,57 @@ const AuditEventsTable: React.FC = () => {
}
};
fetchEvents();
}, [filters, currentPage]);
if (loginEnabled) {
fetchEvents();
} else {
// Provide example audit events when login is disabled
const now = new Date();
setEvents([
{
id: '1',
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
eventType: 'LOGIN',
username: 'admin',
ipAddress: '192.168.1.100',
details: { message: 'User logged in successfully' },
},
{
id: '2',
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
eventType: 'FILE_UPLOAD',
username: 'user1',
ipAddress: '192.168.1.101',
details: { message: 'Uploaded document.pdf' },
},
{
id: '3',
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
eventType: 'SETTINGS_CHANGE',
username: 'admin',
ipAddress: '192.168.1.100',
details: { message: 'Modified system settings' },
},
{
id: '4',
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
eventType: 'FILE_DOWNLOAD',
username: 'user2',
ipAddress: '192.168.1.102',
details: { message: 'Downloaded report.pdf' },
},
{
id: '5',
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
eventType: 'LOGOUT',
username: 'user1',
ipAddress: '192.168.1.101',
details: { message: 'User logged out' },
},
]);
setTotalPages(1);
setLoading(false);
}
}, [filters, currentPage, loginEnabled]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
@ -83,6 +136,7 @@ const AuditEventsTable: React.FC = () => {
users={users}
onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset}
disabled={!loginEnabled}
/>
{/* Table */}
@ -153,6 +207,7 @@ const AuditEventsTable: React.FC = () => {
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>

View File

@ -13,7 +13,11 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditExportSection: React.FC = () => {
interface AuditExportSectionProps {
loginEnabled?: boolean;
}
const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exporting, setExporting] = useState(false);
@ -22,6 +26,8 @@ const AuditExportSection: React.FC = () => {
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters();
const handleExport = async () => {
if (!loginEnabled) return;
try {
setExporting(true);
@ -65,7 +71,11 @@ const AuditExportSection: React.FC = () => {
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => setExportFormat(value as 'csv' | 'json')}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as 'csv' | 'json');
}}
disabled={!loginEnabled}
data={[
{ label: 'CSV', value: 'csv' },
{ label: 'JSON', value: 'json' },
@ -84,6 +94,7 @@ const AuditExportSection: React.FC = () => {
users={users}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
disabled={!loginEnabled}
/>
</div>
@ -93,7 +104,7 @@ const AuditExportSection: React.FC = () => {
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
onClick={handleExport}
loading={exporting}
disabled={exporting}
disabled={!loginEnabled || exporting}
>
{t('audit.export.exportButton', 'Export Data')}
</Button>

View File

@ -11,6 +11,7 @@ interface AuditFiltersFormProps {
users: string[];
onFilterChange: (key: keyof AuditFilters, value: any) => void;
onClearFilters: () => void;
disabled?: boolean;
}
/**
@ -22,6 +23,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
users,
onFilterChange,
onClearFilters,
disabled = false,
}) => {
const { t } = useTranslation();
@ -33,6 +35,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
value={filters.eventType}
onChange={(value) => onFilterChange('eventType', value || undefined)}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@ -43,6 +46,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onChange={(value) => onFilterChange('username', value || undefined)}
clearable
searchable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@ -53,6 +57,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onFilterChange('startDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@ -63,10 +68,11 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onFilterChange('endDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={onClearFilters}>
<Button variant="outline" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
</Button>
</Group>

View File

@ -3,7 +3,7 @@ import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage'
import Login from '@app/routes/Login'
// Login component is used via routing, not directly imported
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
import { accountService } from '@app/services/accountService'
@ -95,13 +95,7 @@ export default function Landing() {
);
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === '/' || location.pathname === '';
if (isHome) {
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
// No session - redirect to login page
// This ensures the URL always shows /login when not authenticated
return <Navigate to="/login" replace state={{ from: location }} />;
}

View File

@ -10,7 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
import OAuthButtons from '@app/routes/login/OAuthButtons';
import OAuthButtons, { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig } from '@app/routes/login/OAuthButtons';
import DividerWithText from '@app/components/shared/DividerWithText';
import LoggedInState from '@app/routes/login/LoggedInState';
import { BASE_PATH } from '@app/constants/app';
@ -26,8 +26,55 @@ export default function Login() {
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
// Handle query params (email prefill and success messages)
// Fetch enabled SSO providers and login config from backend
useEffect(() => {
const fetchProviders = async () => {
try {
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`);
if (response.ok) {
const data = await response.json();
// Check if login is disabled - if so, redirect to home
if (data.enableLogin === false) {
console.debug('[Login] Login disabled, redirecting to home');
navigate('/');
return;
}
setEnableLogin(data.enableLogin ?? true);
// Extract provider IDs from the providerList map
// The keys are like "/oauth2/authorization/google" - extract the last part
const providerIds = Object.keys(data.providerList || {})
.map(key => key.split('/').pop())
.filter((id): id is string => id !== undefined);
setEnabledProviders(providerIds);
}
} catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err);
}
};
fetchProviders();
}, [navigate]);
// Update hasSSOProviders and showEmailForm when enabledProviders changes
useEffect(() => {
// In debug mode, check if any providers exist in the config
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig).length > 0
: enabledProviders.length > 0;
setHasSSOProviders(hasProviders);
// If no SSO providers, show email form by default
if (!hasProviders) {
setShowEmailForm(true);
}
}, [enabledProviders]);
// Handle query params (email prefill, success messages, and session expiry)
useEffect(() => {
try {
const emailFromQuery = searchParams.get('email');
@ -35,6 +82,12 @@ export default function Login() {
setEmail(emailFromQuery);
}
// Check if session expired (401 redirect)
const expired = searchParams.get('expired');
if (expired === 'true') {
setError(t('login.sessionExpired', 'Your session has expired. Please sign in again.'));
}
const messageType = searchParams.get('messageType')
if (messageType) {
switch (messageType) {
@ -71,7 +124,7 @@ export default function Login() {
return <LoggedInState />;
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => {
try {
setIsSigningIn(true);
setError(null);
@ -129,9 +182,10 @@ export default function Login() {
}
};
const handleForgotPassword = () => {
navigate('/auth/reset');
};
// Forgot password handler (currently unused, reserved for future implementation)
// const handleForgotPassword = () => {
// navigate('/auth/reset');
// };
return (
<AuthLayout>
@ -160,25 +214,31 @@ export default function Login() {
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
/>
{/* Divider between OAuth and Email */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
{/* Divider between OAuth and Email - only show if SSO is available */}
{hasSSOProviders && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
{/* Sign in with email button - only show if SSO providers exist */}
{hasSSOProviders && !showEmailForm && (
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked */}
{showEmailForm && (
<div style={{ marginTop: '1rem' }}>
<div style={{ marginTop: hasSSOProviders ? '1rem' : '0' }}>
<EmailPasswordForm
email={email}
password={password}
@ -191,31 +251,6 @@ export default function Login() {
</div>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t('login.forgotPassword', 'Forgot your password?')}
</button>
</div>
)}
{/* Divider then signup link */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
<button
type="button"
onClick={() => navigate('/signup')}
className="auth-link-black"
>
{t('signup.signUp', 'Sign up')}
</button>
</div>
</AuthLayout>
);
}

View File

@ -239,7 +239,7 @@
}
.login-logo-text {
height: 1.5rem; /* 24px */
height: 2rem; /* 32px - increased from 24px */
}
.login-title {

View File

@ -2,11 +2,13 @@ import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
import { useLogoPath } from '@app/hooks/useLogoPath';
export default function LoggedInState() {
const navigate = useNavigate();
const { user } = useAuth();
const { t } = useTranslation();
const logoPath = useLogoPath();
useEffect(() => {
const timer = setTimeout(() => {
@ -34,7 +36,13 @@ export default function LoggedInState() {
padding: '32px'
}}>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'center' }}>
<img
src={logoPath}
alt="Stirling PDF Logo"
style={{ width: '64px', height: '64px', objectFit: 'contain' }}
/>
</div>
<h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#059669', marginBottom: '8px' }}>
{t('login.youAreLoggedIn')}
</h1>

View File

@ -10,7 +10,6 @@ export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
return (
<div className="login-header">
<div className="login-header-logos">
<img src={`${BASE_PATH}/logo192.png`} alt="Logo" className="login-logo-icon" />
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
</div>
<h1 className="login-title">{title}</h1>

View File

@ -1,30 +1,54 @@
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '@app/constants/app';
// OAuth provider configuration
const oauthProviders = [
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false },
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
{ id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true },
{ id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true }
];
// Debug flag to show all providers for UI testing
// Set to true to see all SSO options regardless of backend configuration
export const DEBUG_SHOW_ALL_PROVIDERS = false;
// OAuth provider configuration - maps provider ID to display info
export const oauthProviderConfig = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
apple: { label: 'Apple', file: 'apple.svg' },
azure: { label: 'Microsoft', file: 'microsoft.svg' },
// microsoft and azure are the same, keycloak and oidc need their own icons
// These are commented out from debug view since they need proper icons or backend doesn't use them
// keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
// oidc: { label: 'OIDC', file: 'oidc.svg' }
};
interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => void
isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons'
enabledProviders?: string[] // List of enabled provider IDs from backend
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) {
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) {
const { t } = useTranslation();
// Filter out disabled providers - don't show them at all
const enabledProviders = oauthProviders.filter(p => !p.isDisabled);
// Debug mode: show all providers for UI testing
const providersToShow = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig)
: enabledProviders;
// Filter to only show enabled providers from backend
const providers = providersToShow
.filter(id => id in oauthProviderConfig)
.map(id => ({
id,
...oauthProviderConfig[id as keyof typeof oauthProviderConfig]
}));
// If no providers are enabled, don't render anything
if (providers.length === 0) {
return null;
}
if (layout === 'icons') {
return (
<div className="oauth-container-icons">
{enabledProviders.map((p) => (
{providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
@ -43,7 +67,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
if (layout === 'grid') {
return (
<div className="oauth-container-grid">
{enabledProviders.map((p) => (
{providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
@ -61,7 +85,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
return (
<div className="oauth-container-vertical">
{enabledProviders.map((p) => (
{providers.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}