Merge branch 'V2' into feature/v2/saved-signatures

This commit is contained in:
Reece Browne 2025-11-14 14:03:36 +00:00 committed by GitHub
commit 234095f905
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 1478 additions and 469 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

@ -17,18 +17,11 @@ on:
branches: [main, V2] branches: [main, V2]
paths: paths:
- 'frontend/src-tauri/**' - 'frontend/src-tauri/**'
- 'frontend/src/**' - 'frontend/src/desktop/**'
- 'frontend/package.json' - 'frontend/tsconfig.desktop.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml' - '.github/workflows/tauri-build.yml'
push: push:
branches: [main, V2] branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions: permissions:
contents: read contents: read
@ -344,4 +337,4 @@ jobs:
echo "❌ Some Tauri builds failed." echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues." echo "Please check the logs and fix any issues."
exit 1 exit 1
fi fi

View File

@ -505,10 +505,19 @@ public class ApplicationProperties {
public static class Ui { public static class Ui {
private String appNameNavbar; private String appNameNavbar;
private List<String> languages; private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() { public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null; 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 @Data

View File

@ -75,6 +75,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/login") || trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh") || 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") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs") || trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate") || trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept") || 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 io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup; import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.annotations.api.ConfigApi;
@ -20,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi @ConfigApi
@Hidden @Hidden
@Slf4j
public class ConfigController { public class ConfigController {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@ -59,9 +62,15 @@ public class ConfigController {
// Extract values from ApplicationProperties // Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages()); configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings // 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 // Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled(); boolean smtpEnabled = applicationProperties.getMail().isEnabled();

View File

@ -176,6 +176,7 @@ system:
ui: ui:
appNameNavbar: '' # name displayed on the navigation bar 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. languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints: endpoints:

View File

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

View File

@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|| trimmedUri.startsWith("/saml2") || trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login") || trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh") || 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 { private enum UserLoginType {

View File

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

View File

@ -1,3 +1,6 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events { events {
worker_connections 1024; 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

@ -3230,6 +3230,7 @@
"rememberme": "Remember me", "rememberme": "Remember me",
"invalid": "Invalid username or password.", "invalid": "Invalid username or password.",
"locked": "Your account has been locked.", "locked": "Your account has been locked.",
"sessionExpired": "Your session has expired. Please sign in again.",
"signinTitle": "Please sign in", "signinTitle": "Please sign in",
"ssoSignIn": "Login via Single Sign-on", "ssoSignIn": "Login via Single Sign-on",
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled", "oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
@ -3734,6 +3735,12 @@
"saveSuccess": "Settings saved successfully", "saveSuccess": "Settings saved successfully",
"save": "Save Changes", "save": "Save Changes",
"restartRequired": "Restart Required", "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": { "restart": {
"title": "Restart Required", "title": "Restart Required",
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.", "message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
@ -3804,6 +3811,12 @@
"description": "Default producer for PDF metadata" "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": { "customPaths": {
"label": "Custom Paths", "label": "Custom Paths",
"description": "Configure custom file system paths for pipeline processing and external tools", "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 <div
key={item.key} key={item.key}
onClick={() => { onClick={() => {
if (!isDisabled) { // Allow navigation even when disabled - the content inside will be disabled
setActive(item.key); setActive(item.key);
navigate(`/settings/${item.key}`); navigate(`/settings/${item.key}`);
}
}} }}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`} className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{ style={{
background: isActive ? colors.navItemActiveBg : 'transparent', background: isActive ? colors.navItemActiveBg : 'transparent',
opacity: isDisabled ? 0.5 : 1, opacity: isDisabled ? 0.6 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer', cursor: 'pointer',
}} }}
data-tour={`admin-${item.key}-nav`} data-tour={`admin-${item.key}-nav`}
> >

View File

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

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler'; import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app'; import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
const LandingPage = () => { const LandingPage = () => {
const { addFiles } = useFileHandler(); const { addFiles } = useFileHandler();
@ -14,6 +15,7 @@ const LandingPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext(); const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false); const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath();
const handleFileDrop = async (files: File[]) => { const handleFileDrop = async (files: File[]) => {
await addFiles(files); await addFiles(files);
@ -72,7 +74,7 @@ const LandingPage = () => {
}} }}
> >
<img <img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`} src={logoPath}
alt="Stirling PDF Logo" alt="Stirling PDF Logo"
style={{ style={{
height: 'auto', 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 { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection'; import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection'; 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 { export interface ConfigNavItem {
key: NavKey; key: NavKey;
@ -40,8 +28,8 @@ export interface ConfigColors {
} }
export const createConfigNavSections = ( export const createConfigNavSections = (
isAdmin: boolean = false, _isAdmin: boolean = false,
runningEE: boolean = false, _runningEE: boolean = false,
_loginEnabled: boolean = false _loginEnabled: boolean = false
): ConfigNavSection[] => { ): ConfigNavSection[] => {
const sections: 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; return sections;
}; };

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export interface AppConfig {
serverPort?: number; serverPort?: number;
appNameNavbar?: string; appNameNavbar?: string;
languages?: string[]; languages?: string[];
logoStyle?: 'modern' | 'classic';
enableLogin?: boolean; enableLogin?: boolean;
enableEmailInvites?: boolean; enableEmailInvites?: boolean;
isAdmin?: 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 { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile"; import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
import AppsIcon from '@mui/icons-material/AppsRounded'; import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "@app/components/tools/ToolPanel"; import ToolPanel from "@app/components/tools/ToolPanel";
@ -60,9 +61,7 @@ export default function HomePage() {
}, [config]); }, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${ const brandIconSrc = useLogoPath();
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${ const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black" colorScheme === "dark" ? "White" : "Black"
}Text.svg`; }Text.svg`;

View File

@ -31,4 +31,14 @@ export const accountService = {
formData.append('newPassword', newPassword); formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData); 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) { if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection 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 // Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error); const { title, body } = extractAxiosErrorMessage(error);
@ -112,7 +138,6 @@ export async function handleHttpError(error: any): Promise<boolean> {
// 2) Generic-vs-special dedupe by endpoint // 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url; const url: string | undefined = error?.config?.url;
const status: number | undefined = error?.response?.status;
const now = Date.now(); const now = Date.now();
const isSpecial = const isSpecial =
status === 422 || status === 422 ||

View File

@ -251,7 +251,7 @@ class SpringAuthClient {
* This redirects to the Spring OAuth2 authorization endpoint * This redirects to the Spring OAuth2 authorization endpoint
*/ */
async signInWithOAuth(params: { async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure'; provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
options?: { redirectTo?: string; queryParams?: Record<string, any> }; options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> { }): Promise<{ error: AuthError | null }> {
try { 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'; import { BASE_PATH } from '@app/constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number } type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
export default function LoginRightCarousel({ function LoginRightCarousel({
imageSlides = [], imageSlides = [],
showBackground = true, showBackground = true,
initialSeconds = 5, initialSeconds = 5,
@ -157,3 +157,5 @@ export default function LoginRightCarousel({
</div> </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 { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection'; import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection'; 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'; 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 = ( export const createConfigNavSections = (
isAdmin: boolean = false, isAdmin: boolean = false,
runningEE: boolean = false, runningEE: boolean = false,
loginEnabled: boolean = false loginEnabled: boolean = false
): ConfigNavSection[] => { ): ConfigNavSection[] => {
// Get the core sections // Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(isAdmin, runningEE); const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Workspace section if user is admin // Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin) { if (isAdmin || !loginEnabled) {
const workspaceSection: ConfigNavSection = { const requiresLogin = !loginEnabled;
// Workspace
sections.push({
title: 'Workspace', title: 'Workspace',
items: [ items: [
{ {
key: 'people', key: 'people',
label: 'People', label: 'People',
icon: 'group-rounded', icon: 'group-rounded',
component: <PeopleSection /> component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
}, },
{ {
key: 'teams', key: 'teams',
label: 'Teams', label: 'Teams',
icon: 'groups-rounded', icon: 'groups-rounded',
component: <TeamsSection /> component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
}, },
], ],
}; });
// Insert workspace section after Preferences (at index 1) // Configuration
sections.splice(1, 0, workspaceSection); 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 // 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 { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge'; import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient'; import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface AdvancedSettingsData { interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean; enableAlphaFunctionality?: boolean;
@ -55,6 +57,7 @@ interface AdvancedSettingsData {
export default function AdminAdvancedSection() { export default function AdminAdvancedSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { const {
settings, settings,
@ -165,10 +168,15 @@ export default function AdminAdvancedSection() {
}); });
useEffect(() => { useEffect(() => {
fetchSettings(); if (loginEnabled) {
}, []); fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => { const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try { try {
await saveSettings(); await saveSettings();
showRestartModal(); showRestartModal();
@ -181,7 +189,9 @@ export default function AdminAdvancedSection() {
} }
}; };
if (loading) { const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return ( return (
<Stack align="center" justify="center" h={200}> <Stack align="center" justify="center" h={200}>
<Loader size="lg" /> <Loader size="lg" />
@ -191,6 +201,7 @@ export default function AdminAdvancedSection() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div> <div>
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text> <Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@ -213,7 +224,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs"> <Group gap="xs">
<Switch <Switch
checked={settings.enableAlphaFunctionality || false} 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')} /> <PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
</Group> </Group>
@ -229,7 +245,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs"> <Group gap="xs">
<Switch <Switch
checked={settings.enableUrlToPDF || false} 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')} /> <PendingBadge show={isFieldPending('enableUrlToPDF')} />
</Group> </Group>
@ -245,7 +266,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs"> <Group gap="xs">
<Switch <Switch
checked={settings.disableSanitize || false} 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')} /> <PendingBadge show={isFieldPending('disableSanitize')} />
</Group> </Group>
@ -271,6 +297,7 @@ export default function AdminAdvancedSection() {
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })} onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0} min={0}
max={3000} max={3000}
disabled={!loginEnabled}
/> />
</div> </div>
@ -286,6 +313,7 @@ export default function AdminAdvancedSection() {
value={settings.tessdataDir || ''} value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })} onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata" placeholder="/usr/share/tessdata"
disabled={!loginEnabled}
/> />
</div> </div>
</Stack> </Stack>
@ -311,6 +339,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value } tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
})} })}
placeholder="Default: java.io.tmpdir/stirling-pdf" placeholder="Default: java.io.tmpdir/stirling-pdf"
disabled={!loginEnabled}
/> />
</div> </div>
@ -324,6 +353,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value } tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
})} })}
placeholder="Default: baseTmpDir/libreoffice" placeholder="Default: baseTmpDir/libreoffice"
disabled={!loginEnabled}
/> />
</div> </div>
@ -337,6 +367,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value } tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
})} })}
placeholder="System temp directory path" placeholder="System temp directory path"
disabled={!loginEnabled}
/> />
</div> </div>
@ -350,6 +381,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value } tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
})} })}
placeholder="stirling-pdf-" placeholder="stirling-pdf-"
disabled={!loginEnabled}
/> />
</div> </div>
@ -364,6 +396,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={720} max={720}
disabled={!loginEnabled}
/> />
</div> </div>
@ -378,6 +411,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={1440} max={1440}
disabled={!loginEnabled}
/> />
</div> </div>
@ -391,10 +425,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs"> <Group gap="xs">
<Switch <Switch
checked={settings.tempFileManagement?.startupCleanup ?? true} checked={settings.tempFileManagement?.startupCleanup ?? true}
onChange={(e) => setSettings({ onChange={(e) => {
...settings, if (!loginEnabled) return;
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked } setSettings({
})} ...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/> />
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} /> <PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
</Group> </Group>
@ -410,10 +449,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs"> <Group gap="xs">
<Switch <Switch
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false} checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
onChange={(e) => setSettings({ onChange={(e) => {
...settings, if (!loginEnabled) return;
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked } setSettings({
})} ...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/> />
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} /> <PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
</Group> </Group>
@ -448,6 +492,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -462,6 +507,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -485,6 +531,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -499,6 +546,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -522,6 +570,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -536,6 +585,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -559,6 +609,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -573,6 +624,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -596,6 +648,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -610,6 +663,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -633,6 +687,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -647,6 +702,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -670,6 +726,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -684,6 +741,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -707,6 +765,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -721,6 +780,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -744,6 +804,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -758,6 +819,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -781,6 +843,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={100} max={100}
disabled={!loginEnabled}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -795,6 +858,7 @@ export default function AdminAdvancedSection() {
})} })}
min={1} min={1}
max={240} max={240}
disabled={!loginEnabled}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
@ -805,7 +869,7 @@ export default function AdminAdvancedSection() {
{/* Save Button */} {/* Save Button */}
<Group justify="flex-end"> <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')} {t('admin.settings.save', 'Save Changes')}
</Button> </Button>
</Group> </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 AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable'; import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection'; 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 AdminAuditSection: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null); const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" /> <Loader size="lg" />
@ -56,32 +73,33 @@ const AdminAuditSection: React.FC = () => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<AuditSystemStatus status={systemStatus} /> <AuditSystemStatus status={systemStatus} />
{systemStatus.enabled ? ( {systemStatus.enabled ? (
<Tabs defaultValue="dashboard"> <Tabs defaultValue="dashboard">
<Tabs.List> <Tabs.List>
<Tabs.Tab value="dashboard"> <Tabs.Tab value="dashboard" disabled={!loginEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')} {t('audit.tabs.dashboard', 'Dashboard')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="events"> <Tabs.Tab value="events" disabled={!loginEnabled}>
{t('audit.tabs.events', 'Audit Events')} {t('audit.tabs.events', 'Audit Events')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="export"> <Tabs.Tab value="export" disabled={!loginEnabled}>
{t('audit.tabs.export', 'Export')} {t('audit.tabs.export', 'Export')}
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="dashboard" pt="md"> <Tabs.Panel value="dashboard" pt="md">
<AuditChartsSection /> <AuditChartsSection loginEnabled={loginEnabled} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="events" pt="md"> <Tabs.Panel value="events" pt="md">
<AuditEventsTable /> <AuditEventsTable loginEnabled={loginEnabled} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="export" pt="md"> <Tabs.Panel value="export" pt="md">
<AuditExportSection /> <AuditExportSection loginEnabled={loginEnabled} />
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
) : ( ) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,9 +14,12 @@ import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart'; import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable'; import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
import LocalIcon from '@app/components/shared/LocalIcon'; 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 AdminUsageSection: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [data, setData] = useState<EndpointStatisticsResponse | null>(null); const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -24,6 +27,10 @@ const AdminUsageSection: React.FC = () => {
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all'); const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const fetchData = async () => { const fetchData = async () => {
if (!validateLoginEnabled()) {
return;
}
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -40,10 +47,55 @@ const AdminUsageSection: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
fetchData(); if (loginEnabled) {
}, [displayMode, dataType]); 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 = () => { const handleRefresh = () => {
if (!validateLoginEnabled()) {
return;
}
fetchData(); 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 // Early returns for loading/error states
if (loading) { if (actualLoading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<Loader size="lg" /> <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 const displayedPercentage = (data?.totalVisits || 0) > 0
? ((displayedVisits / data.totalVisits) * 100).toFixed(1) ? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
: '0'; : '0';
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
{/* Controls */} {/* Controls */}
<Card padding="lg" radius="md" withBorder> <Card padding="lg" radius="md" withBorder>
<Stack gap="md"> <Stack gap="md">
@ -103,6 +160,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl <SegmentedControl
value={displayMode} value={displayMode}
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')} onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
disabled={!loginEnabled}
data={[ data={[
{ {
value: 'top10', value: 'top10',
@ -123,6 +181,7 @@ const AdminUsageSection: React.FC = () => {
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />} leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={handleRefresh} onClick={handleRefresh}
loading={loading} loading={loading}
disabled={!loginEnabled}
> >
{t('usage.controls.refresh', 'Refresh')} {t('usage.controls.refresh', 'Refresh')}
</Button> </Button>
@ -136,6 +195,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl <SegmentedControl
value={dataType} value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')} onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
disabled={!loginEnabled}
data={[ data={[
{ {
value: 'all', value: 'all',

View File

@ -28,10 +28,13 @@ import { userManagementService, User } from '@app/services/userManagementService
import { teamService, Team } from '@app/services/teamService'; import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
export default function PeopleSection() { export default function PeopleSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useAppConfig(); const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -97,30 +100,103 @@ export default function PeopleSection() {
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true); setLoading(true);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
// Enrich users with session data if (loginEnabled) {
const enrichedUsers = adminData.users.map(user => ({ const [adminData, teamsData] = await Promise.all([
...user, userManagementService.getUsers(),
isActive: adminData.userSessions[user.username] || false, teamService.getTeams(),
lastRequest: adminData.userLastRequest[user.username] || undefined, ]);
}));
setUsers(enrichedUsers); // Enrich users with session data
setTeams(teamsData); const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
// Store license information setUsers(enrichedUsers);
setLicenseInfo({ setTeams(teamsData);
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots, // Store license information
grandfatheredUserCount: adminData.grandfatheredUserCount, setLicenseInfo({
licenseMaxUsers: adminData.licenseMaxUsers, maxAllowedUsers: adminData.maxAllowedUsers,
premiumEnabled: adminData.premiumEnabled, availableSlots: adminData.availableSlots,
totalUsers: adminData.totalUsers, 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) { } catch (error) {
console.error('Failed to fetch people data:', error); console.error('Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' }); alert({ alertType: 'error', title: 'Failed to load people data' });
@ -405,6 +481,7 @@ export default function PeopleSection() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div> <div>
<Text fw={600} size="lg"> <Text fw={600} size="lg">
{t('workspace.people.title')} {t('workspace.people.title')}
@ -457,15 +534,15 @@ export default function PeopleSection() {
style={{ maxWidth: 300 }} style={{ maxWidth: 300 }}
/> />
<Tooltip <Tooltip
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')} label={!loginEnabled ? 'Enable login mode first' : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
disabled={!licenseInfo || licenseInfo.availableSlots > 0} disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
position="bottom" position="bottom"
withArrow withArrow
> >
<Button <Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />} leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setInviteModalOpened(true)} onClick={() => setInviteModalOpened(true)}
disabled={licenseInfo ? licenseInfo.availableSlots === 0 : false} disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
> >
{t('workspace.people.addMembers')} {t('workspace.people.addMembers')}
</Button> </Button>
@ -616,20 +693,21 @@ export default function PeopleSection() {
{/* Actions menu */} {/* Actions menu */}
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" /> <LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}> <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 <Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />} leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
onClick={() => handleToggleEnabled(user)} onClick={() => handleToggleEnabled(user)}
disabled={!loginEnabled}
> >
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')} {user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <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')} {t('workspace.people.deleteUser')}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>

View File

@ -22,9 +22,12 @@ import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService'; import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection'; 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() { export default function TeamsSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
@ -47,8 +50,18 @@ export default function TeamsSection() {
const fetchTeams = async () => { const fetchTeams = async () => {
try { try {
setLoading(true); setLoading(true);
const teamsData = await teamService.getTeams(); if (loginEnabled) {
setTeams(teamsData); 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) { } catch (error) {
console.error('Failed to fetch teams:', error); console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' }); alert({ alertType: 'error', title: 'Failed to load teams' });
@ -207,6 +220,7 @@ export default function TeamsSection() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div> <div>
<Text fw={600} size="lg"> <Text fw={600} size="lg">
{t('workspace.teams.title')} {t('workspace.teams.title')}
@ -218,7 +232,7 @@ export default function TeamsSection() {
{/* Header Actions */} {/* Header Actions */}
<Group justify="flex-end"> <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')} {t('workspace.teams.createNewTeam')}
</Button> </Button>
</Group> </Group>
@ -257,8 +271,8 @@ export default function TeamsSection() {
teams.map((team) => ( teams.map((team) => (
<Table.Tr <Table.Tr
key={team.id} key={team.id}
style={{ cursor: 'pointer' }} style={{ cursor: loginEnabled ? 'pointer' : 'default' }}
onClick={() => setViewingTeamId(team.id)} onClick={() => loginEnabled && setViewingTeamId(team.id)}
> >
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
@ -290,18 +304,18 @@ export default function TeamsSection() {
<Table.Td onClick={(e) => e.stopPropagation()}> <Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" /> <LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}> <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')} {t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item> </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')} {t('workspace.teams.addMember')}
</Menu.Item> </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')} {t('workspace.teams.renameTeamLabel')}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
@ -309,7 +323,7 @@ export default function TeamsSection() {
color="red" color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)} onClick={() => handleDeleteTeam(team)}
disabled={team.name === 'Internal'} disabled={!loginEnabled || team.name === 'Internal'}
> >
{t('workspace.teams.deleteTeamLabel')} {t('workspace.teams.deleteTeamLabel')}
</Menu.Item> </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 { t } = useTranslation();
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week'); const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null); const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
@ -74,8 +78,27 @@ const AuditChartsSection: React.FC = () => {
} }
}; };
fetchChartsData(); if (loginEnabled) {
}, [timePeriod]); 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) { if (loading) {
return ( return (
@ -123,7 +146,11 @@ const AuditChartsSection: React.FC = () => {
</Text> </Text>
<SegmentedControl <SegmentedControl
value={timePeriod} value={timePeriod}
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')} onChange={(value) => {
if (!loginEnabled) return;
setTimePeriod(value as 'day' | 'week' | 'month');
}}
disabled={!loginEnabled}
data={[ data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' }, { label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' }, { 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 { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm'; 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 { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]); const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
@ -51,8 +55,57 @@ const AuditEventsTable: React.FC = () => {
} }
}; };
fetchEvents(); if (loginEnabled) {
}, [filters, currentPage]); 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 // Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => { const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
@ -83,6 +136,7 @@ const AuditEventsTable: React.FC = () => {
users={users} users={users}
onFilterChange={handleFilterChangeWithReset} onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset} onClearFilters={handleClearFiltersWithReset}
disabled={!loginEnabled}
/> />
{/* Table */} {/* Table */}
@ -153,6 +207,7 @@ const AuditEventsTable: React.FC = () => {
variant="subtle" variant="subtle"
size="xs" size="xs"
onClick={() => setSelectedEvent(event)} onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
> >
{t('audit.events.viewDetails', 'View Details')} {t('audit.events.viewDetails', 'View Details')}
</Button> </Button>

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession' import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext' import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage' 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 FirstLoginModal from '@app/components/shared/FirstLoginModal'
import { accountService } from '@app/services/accountService' 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) // No session - redirect to login page
// Otherwise navigate to login (fixes URL mismatch for tool routes) // This ensures the URL always shows /login when not authenticated
const isHome = location.pathname === '/' || location.pathname === '';
if (isHome) {
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />; 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 LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage'; import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'; 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 DividerWithText from '@app/components/shared/DividerWithText';
import LoggedInState from '@app/routes/login/LoggedInState'; import LoggedInState from '@app/routes/login/LoggedInState';
import { BASE_PATH } from '@app/constants/app'; import { BASE_PATH } from '@app/constants/app';
@ -26,8 +26,55 @@ export default function Login() {
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = 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(() => { useEffect(() => {
try { try {
const emailFromQuery = searchParams.get('email'); const emailFromQuery = searchParams.get('email');
@ -35,6 +82,12 @@ export default function Login() {
setEmail(emailFromQuery); 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') const messageType = searchParams.get('messageType')
if (messageType) { if (messageType) {
switch (messageType) { switch (messageType) {
@ -71,7 +124,7 @@ export default function Login() {
return <LoggedInState />; return <LoggedInState />;
} }
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => { const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => {
try { try {
setIsSigningIn(true); setIsSigningIn(true);
setError(null); setError(null);
@ -129,9 +182,10 @@ export default function Login() {
} }
}; };
const handleForgotPassword = () => { // Forgot password handler (currently unused, reserved for future implementation)
navigate('/auth/reset'); // const handleForgotPassword = () => {
}; // navigate('/auth/reset');
// };
return ( return (
<AuthLayout> <AuthLayout>
@ -160,25 +214,31 @@ export default function Login() {
onProviderClick={signInWithProvider} onProviderClick={signInWithProvider}
isSubmitting={isSigningIn} isSubmitting={isSigningIn}
layout="vertical" layout="vertical"
enabledProviders={enabledProviders}
/> />
{/* Divider between OAuth and Email */} {/* Divider between OAuth and Email - only show if SSO is available */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} /> {hasSSOProviders && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
{/* Sign in with email button (primary color to match signup CTA) */} {/* Sign in with email button - only show if SSO providers exist */}
<div className="auth-section"> {hasSSOProviders && !showEmailForm && (
<button <div className="auth-section">
type="button" <button
onClick={() => setShowEmailForm(true)} type="button"
disabled={isSigningIn} onClick={() => setShowEmailForm(true)}
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" 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> {t('login.useEmailInstead', 'Login with email')}
</div> </button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked */}
{showEmailForm && ( {showEmailForm && (
<div style={{ marginTop: '1rem' }}> <div style={{ marginTop: hasSSOProviders ? '1rem' : '0' }}>
<EmailPasswordForm <EmailPasswordForm
email={email} email={email}
password={password} password={password}
@ -191,31 +251,6 @@ export default function Login() {
</div> </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> </AuthLayout>
); );
} }

View File

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

View File

@ -2,11 +2,13 @@ import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '@app/auth/UseSession'; import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLogoPath } from '@app/hooks/useLogoPath';
export default function LoggedInState() { export default function LoggedInState() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { t } = useTranslation(); const { t } = useTranslation();
const logoPath = useLogoPath();
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -34,7 +36,13 @@ export default function LoggedInState() {
padding: '32px' padding: '32px'
}}> }}>
<div style={{ textAlign: 'center', marginBottom: '24px' }}> <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' }}> <h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#059669', marginBottom: '8px' }}>
{t('login.youAreLoggedIn')} {t('login.youAreLoggedIn')}
</h1> </h1>

View File

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

View File

@ -1,30 +1,54 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '@app/constants/app'; import { BASE_PATH } from '@app/constants/app';
// OAuth provider configuration // Debug flag to show all providers for UI testing
const oauthProviders = [ // Set to true to see all SSO options regardless of backend configuration
{ id: 'google', label: 'Google', file: 'google.svg', isDisabled: false }, export const DEBUG_SHOW_ALL_PROVIDERS = false;
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
{ id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true }, // OAuth provider configuration - maps provider ID to display info
{ id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true } 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 { interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => void
isSubmitting: boolean isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons' 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(); const { t } = useTranslation();
// Filter out disabled providers - don't show them at all // Debug mode: show all providers for UI testing
const enabledProviders = oauthProviders.filter(p => !p.isDisabled); 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') { if (layout === 'icons') {
return ( return (
<div className="oauth-container-icons"> <div className="oauth-container-icons">
{enabledProviders.map((p) => ( {providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}> <div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button <button
onClick={() => onProviderClick(p.id as any)} onClick={() => onProviderClick(p.id as any)}
@ -43,7 +67,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
if (layout === 'grid') { if (layout === 'grid') {
return ( return (
<div className="oauth-container-grid"> <div className="oauth-container-grid">
{enabledProviders.map((p) => ( {providers.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}> <div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button <button
onClick={() => onProviderClick(p.id as any)} onClick={() => onProviderClick(p.id as any)}
@ -61,7 +85,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
return ( return (
<div className="oauth-container-vertical"> <div className="oauth-container-vertical">
{enabledProviders.map((p) => ( {providers.map((p) => (
<button <button
key={p.id} key={p.id}
onClick={() => onProviderClick(p.id as any)} onClick={() => onProviderClick(p.id as any)}