Remove ambiguous translations and fix invalid translations (#4841)

# Description of Changes
`i18next` allows this pattern for translations, which we use quite a few
times in our current translation files:

```json
{
  "a": {
     "b": "hello"
   },
   "a.b": "world"
}
```

This makes it ambiguous when selecting `a.b` which string will be
retrieved. We have seen issues in other languages in the current release
like this:

<img width="325" height="249" alt="image"
src="https://github.com/user-attachments/assets/f24a29f0-550f-49b8-b355-c5e5eb436558"
/>

because we are expecting this:

<img width="1022" height="210" alt="image"
src="https://github.com/user-attachments/assets/b6d5cdd4-96cd-4b2b-8f1a-465da8bf70c8"
/>

but the Spanish file has:

<img width="312" height="136" alt="image"
src="https://github.com/user-attachments/assets/1e13392c-8484-47d1-b0c4-19d52b3ea5eb"
/>

and no `removeDigitalSignature` key on its own. 

This PR resolves all of these ambiguities in the source by restructuring
all of the keys to uniquely target either an object or a string, not
both. It also adds a test which will fail on any keys with a `.` in
their name, therefore making it impossible to add anything ambiguous.
This commit is contained in:
James Brunton
2025-11-10 11:03:18 +00:00
committed by GitHub
parent 9671f6835e
commit 45389340ed
59 changed files with 142348 additions and 14174 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+542 -316
View File
@@ -473,7 +473,14 @@
"globalPopularity": "Global Popularity", "globalPopularity": "Global Popularity",
"sortBy": "Sort by:", "sortBy": "Sort by:",
"mobile": { "mobile": {
"brandAlt": "Stirling PDF logo" "brandAlt": "Stirling PDF logo",
"openFiles": "Open files",
"swipeHint": "Swipe left or right to switch views",
"tools": "Tools",
"toolsSlide": "Tool selection panel",
"viewSwitcher": "Switch workspace view",
"workbenchSlide": "Workspace panel",
"workspace": "Workspace"
}, },
"multiTool": { "multiTool": {
"tags": "multiple,tools", "tags": "multiple,tools",
@@ -765,16 +772,6 @@
"title": "Automate", "title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}, },
"mobile": {
"brandAlt": "Stirling PDF logo",
"openFiles": "Open files",
"swipeHint": "Swipe left or right to switch views",
"tools": "Tools",
"toolsSlide": "Tool selection panel",
"viewSwitcher": "Switch workspace view",
"workbenchSlide": "Workspace panel",
"workspace": "Workspace"
},
"overlay-pdfs": { "overlay-pdfs": {
"desc": "Overlay one PDF on top of another", "desc": "Overlay one PDF on top of another",
"title": "Overlay PDFs" "title": "Overlay PDFs"
@@ -817,15 +814,19 @@
"merge": { "merge": {
"tags": "merge,Page operations,Back end,server side", "tags": "merge,Page operations,Back end,server side",
"title": "Merge", "title": "Merge",
"removeDigitalSignature": "Remove digital signature in the merged file?", "removeDigitalSignature": {
"generateTableOfContents": "Generate table of contents in the merged file?", "label": "Remove digital signature in the merged file?",
"removeDigitalSignature.tooltip": { "tooltip": {
"title": "Remove Digital Signature", "title": "Remove Digital Signature",
"description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF." "description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF."
}
}, },
"generateTableOfContents.tooltip": { "generateTableOfContents": {
"title": "Generate Table of Contents", "label": "Generate table of contents in the merged file?",
"description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers." "tooltip": {
"title": "Generate Table of Contents",
"description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers."
}
}, },
"submit": "Merge", "submit": "Merge",
"sortBy": { "sortBy": {
@@ -2035,8 +2036,10 @@
"options": { "options": {
"stepTitle": "Flatten Options", "stepTitle": "Flatten Options",
"title": "Flatten Options", "title": "Flatten Options",
"flattenOnlyForms": "Flatten only forms", "flattenOnlyForms": {
"flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact", "label": "Flatten only forms",
"desc": "Only flatten form fields, leaving other interactive elements intact"
},
"note": "Flattening removes interactive elements from the PDF, making them non-editable." "note": "Flattening removes interactive elements from the PDF, making them non-editable."
}, },
"results": { "results": {
@@ -3127,13 +3130,14 @@
"passwordsDoNotMatch": "Passwords do not match", "passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters long", "passwordTooShort": "Password must be at least 6 characters long",
"invalidEmail": "Please enter a valid email address", "invalidEmail": "Please enter a valid email address",
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
"unexpectedError": "Unexpected error: {{message}}",
"useEmailInstead": "Use Email Instead",
"nameRequired": "Name is required", "nameRequired": "Name is required",
"emailRequired": "Email is required", "emailRequired": "Email is required",
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
"confirmPasswordRequired": "Confirm password is required", "confirmPasswordRequired": "Please confirm your password"
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
"unexpectedError": "Unexpected error: {{message}}"
}, },
"pdfToSinglePage": { "pdfToSinglePage": {
"title": "PDF To Single Page", "title": "PDF To Single Page",
@@ -3570,45 +3574,87 @@
"description": "Configure system-wide application settings including branding and default behaviour.", "description": "Configure system-wide application settings including branding and default behaviour.",
"ui": "User Interface", "ui": "User Interface",
"system": "System", "system": "System",
"appName": "Application Name", "appName": {
"appName.description": "The name displayed in the browser tab and home page", "label": "Application Name",
"appNameNavbar": "Navbar Brand", "description": "The name displayed in the browser tab and home page"
"appNameNavbar.description": "The name displayed in the navigation bar", },
"homeDescription": "Home Description", "appNameNavbar": {
"homeDescription.description": "The description text shown on the home page", "label": "Navbar Brand",
"defaultLocale": "Default Locale", "description": "The name displayed in the navigation bar"
"defaultLocale.description": "The default language for new users (e.g., en_US, es_ES)", },
"fileUploadLimit": "File Upload Limit", "homeDescription": {
"fileUploadLimit.description": "Maximum file upload size (e.g., 100MB, 1GB)", "label": "Home Description",
"showUpdate": "Show Update Notifications", "description": "The description text shown on the home page"
"showUpdate.description": "Display notifications when a new version is available", },
"showUpdateOnlyAdmin": "Show Updates to Admins Only", "defaultLocale": {
"showUpdateOnlyAdmin.description": "Restrict update notifications to admin users only", "label": "Default Locale",
"customHTMLFiles": "Custom HTML Files", "description": "The default language for new users (e.g., en_US, es_ES)"
"customHTMLFiles.description": "Allow serving custom HTML files from the customFiles directory", },
"languages": "Available Languages", "fileUploadLimit": {
"languages.description": "Languages that users can select from (leave empty to enable all languages)", "label": "File Upload Limit",
"customMetadata": "Custom Metadata", "description": "Maximum file upload size (e.g., 100MB, 1GB)"
"customMetadata.autoUpdate": "Auto Update Metadata", },
"customMetadata.autoUpdate.description": "Automatically update PDF metadata on all processed documents", "showUpdate": {
"customMetadata.author": "Default Author", "label": "Show Update Notifications",
"customMetadata.author.description": "Default author for PDF metadata (e.g., username)", "description": "Display notifications when a new version is available"
"customMetadata.creator": "Default Creator", },
"customMetadata.creator.description": "Default creator for PDF metadata", "showUpdateOnlyAdmin": {
"customMetadata.producer": "Default Producer", "label": "Show Updates to Admins Only",
"customMetadata.producer.description": "Default producer for PDF metadata", "description": "Restrict update notifications to admin users only"
"customPaths": "Custom Paths", },
"customPaths.description": "Configure custom file system paths for pipeline processing and external tools", "customHTMLFiles": {
"customPaths.pipeline": "Pipeline Directories", "label": "Custom HTML Files",
"customPaths.pipeline.watchedFoldersDir": "Watched Folders Directory", "description": "Allow serving custom HTML files from the customFiles directory"
"customPaths.pipeline.watchedFoldersDir.description": "Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)", },
"customPaths.pipeline.finishedFoldersDir": "Finished Folders Directory", "languages": {
"customPaths.pipeline.finishedFoldersDir.description": "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)", "label": "Available Languages",
"customPaths.operations": "External Tool Paths", "description": "Languages that users can select from (leave empty to enable all languages)"
"customPaths.operations.weasyprint": "WeasyPrint Executable", },
"customPaths.operations.weasyprint.description": "Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)", "customMetadata": {
"customPaths.operations.unoconvert": "Unoconvert Executable", "label": "Custom Metadata",
"customPaths.operations.unoconvert.description": "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)" "autoUpdate": {
"label": "Auto Update Metadata",
"description": "Automatically update PDF metadata on all processed documents"
},
"author": {
"label": "Default Author",
"description": "Default author for PDF metadata (e.g., username)"
},
"creator": {
"label": "Default Creator",
"description": "Default creator for PDF metadata"
},
"producer": {
"label": "Default Producer",
"description": "Default producer for PDF metadata"
}
},
"customPaths": {
"label": "Custom Paths",
"description": "Configure custom file system paths for pipeline processing and external tools",
"pipeline": {
"label": "Pipeline Directories",
"watchedFoldersDir": {
"label": "Watched Folders Directory",
"description": "Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)"
},
"finishedFoldersDir": {
"label": "Finished Folders Directory",
"description": "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)"
}
},
"operations": {
"label": "External Tool Paths",
"weasyprint": {
"label": "WeasyPrint Executable",
"description": "Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)"
},
"unoconvert": {
"label": "Unoconvert Executable",
"description": "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
}
}
}
}, },
"security": { "security": {
"title": "Security", "title": "Security",
@@ -3618,68 +3664,124 @@
"message": "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management." "message": "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management."
}, },
"authentication": "Authentication", "authentication": "Authentication",
"enableLogin": "Enable Login", "enableLogin": {
"enableLogin.description": "Require users to log in before accessing the application", "label": "Enable Login",
"loginMethod": "Login Method", "description": "Require users to log in before accessing the application"
"loginMethod.description": "The authentication method to use for user login", },
"loginMethod.all": "All Methods", "loginMethod": {
"loginMethod.normal": "Username/Password Only", "label": "Login Method",
"loginMethod.oauth2": "OAuth2 Only", "description": "The authentication method to use for user login",
"loginMethod.saml2": "SAML2 Only", "all": "All Methods",
"loginAttemptCount": "Login Attempt Limit", "normal": "Username/Password Only",
"loginAttemptCount.description": "Maximum number of failed login attempts before account lockout", "oauth2": "OAuth2 Only",
"loginResetTimeMinutes": "Login Reset Time (minutes)", "saml2": "SAML2 Only"
"loginResetTimeMinutes.description": "Time before failed login attempts are reset", },
"csrfDisabled": "Disable CSRF Protection", "loginAttemptCount": {
"csrfDisabled.description": "Disable Cross-Site Request Forgery protection (not recommended)", "label": "Login Attempt Limit",
"initialLogin": "Initial Login", "description": "Maximum number of failed login attempts before account lockout"
"initialLogin.username": "Initial Username", },
"initialLogin.username.description": "The username for the initial admin account", "loginResetTimeMinutes": {
"initialLogin.password": "Initial Password", "label": "Login Reset Time (minutes)",
"initialLogin.password.description": "The password for the initial admin account", "description": "Time before failed login attempts are reset"
"jwt": "JWT Configuration", },
"jwt.secureCookie": "Secure Cookie", "csrfDisabled": {
"jwt.secureCookie.description": "Require HTTPS for JWT cookies (recommended for production)", "label": "Disable CSRF Protection",
"jwt.keyRetentionDays": "Key Retention Days", "description": "Disable Cross-Site Request Forgery protection (not recommended)"
"jwt.keyRetentionDays.description": "Number of days to retain old JWT keys for verification", },
"jwt.persistence": "Enable Key Persistence", "initialLogin": {
"jwt.persistence.description": "Store JWT keys persistently to survive server restarts", "label": "Initial Login",
"jwt.enableKeyRotation": "Enable Key Rotation", "username": {
"jwt.enableKeyRotation.description": "Automatically rotate JWT signing keys periodically", "label": "Initial Username",
"jwt.enableKeyCleanup": "Enable Key Cleanup", "description": "The username for the initial admin account"
"jwt.enableKeyCleanup.description": "Automatically remove expired JWT keys", },
"audit": "Audit Logging", "password": {
"audit.enabled": "Enable Audit Logging", "label": "Initial Password",
"audit.enabled.description": "Track user actions and system events for compliance and security monitoring", "description": "The password for the initial admin account"
"audit.level": "Audit Level", }
"audit.level.description": "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE", },
"audit.retentionDays": "Audit Retention (days)", "jwt": {
"audit.retentionDays.description": "Number of days to retain audit logs", "label": "JWT Configuration",
"htmlUrlSecurity": "HTML URL Security", "secureCookie": {
"htmlUrlSecurity.description": "Configure URL access restrictions for HTML processing to prevent SSRF attacks", "label": "Secure Cookie",
"htmlUrlSecurity.enabled": "Enable URL Security", "description": "Require HTTPS for JWT cookies (recommended for production)"
"htmlUrlSecurity.enabled.description": "Enable URL security restrictions for HTML to PDF conversions", },
"htmlUrlSecurity.level": "Security Level", "keyRetentionDays": {
"htmlUrlSecurity.level.description": "MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions", "label": "Key Retention Days",
"htmlUrlSecurity.level.max": "Maximum (Whitelist Only)", "description": "Number of days to retain old JWT keys for verification"
"htmlUrlSecurity.level.medium": "Medium (Block Internal)", },
"htmlUrlSecurity.level.off": "Off (No Restrictions)", "persistence": {
"htmlUrlSecurity.advanced": "Advanced Settings", "label": "Enable Key Persistence",
"htmlUrlSecurity.allowedDomains": "Allowed Domains (Whitelist)", "description": "Store JWT keys persistently to survive server restarts"
"htmlUrlSecurity.allowedDomains.description": "One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX", },
"htmlUrlSecurity.blockedDomains": "Blocked Domains (Blacklist)", "enableKeyRotation": {
"htmlUrlSecurity.blockedDomains.description": "One domain per line (e.g., malicious.com). Additional domains to block", "label": "Enable Key Rotation",
"htmlUrlSecurity.internalTlds": "Internal TLDs", "description": "Automatically rotate JWT signing keys periodically"
"htmlUrlSecurity.internalTlds.description": "One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns", },
"htmlUrlSecurity.networkBlocking": "Network Blocking", "enableKeyCleanup": {
"htmlUrlSecurity.blockPrivateNetworks": "Block Private Networks", "label": "Enable Key Cleanup",
"htmlUrlSecurity.blockPrivateNetworks.description": "Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)", "description": "Automatically remove expired JWT keys"
"htmlUrlSecurity.blockLocalhost": "Block Localhost", }
"htmlUrlSecurity.blockLocalhost.description": "Block localhost and loopback addresses (127.x.x.x, ::1)", },
"htmlUrlSecurity.blockLinkLocal": "Block Link-Local Addresses", "audit": {
"htmlUrlSecurity.blockLinkLocal.description": "Block link-local addresses (169.254.x.x, fe80::/10)", "label": "Audit Logging",
"htmlUrlSecurity.blockCloudMetadata": "Block Cloud Metadata Endpoints", "enabled": {
"htmlUrlSecurity.blockCloudMetadata.description": "Block cloud provider metadata endpoints (169.254.169.254)" "label": "Enable Audit Logging",
"description": "Track user actions and system events for compliance and security monitoring"
},
"level": {
"label": "Audit Level",
"description": "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE"
},
"retentionDays": {
"label": "Audit Retention (days)",
"description": "Number of days to retain audit logs"
}
},
"htmlUrlSecurity": {
"label": "HTML URL Security",
"description": "Configure URL access restrictions for HTML processing to prevent SSRF attacks",
"enabled": {
"label": "Enable URL Security",
"description": "Enable URL security restrictions for HTML to PDF conversions"
},
"level": {
"label": "Security Level",
"description": "MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions",
"max": "Maximum (Whitelist Only)",
"medium": "Medium (Block Internal)",
"off": "Off (No Restrictions)"
},
"advanced": "Advanced Settings",
"allowedDomains": {
"label": "Allowed Domains (Whitelist)",
"description": "One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX"
},
"blockedDomains": {
"label": "Blocked Domains (Blacklist)",
"description": "One domain per line (e.g., malicious.com). Additional domains to block"
},
"internalTlds": {
"label": "Internal TLDs",
"description": "One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns"
},
"networkBlocking": "Network Blocking",
"blockPrivateNetworks": {
"label": "Block Private Networks",
"description": "Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)"
},
"blockLocalhost": {
"label": "Block Localhost",
"description": "Block localhost and loopback addresses (127.x.x.x, ::1)"
},
"blockLinkLocal": {
"label": "Block Link-Local Addresses",
"description": "Block link-local addresses (169.254.x.x, fe80::/10)"
},
"blockCloudMetadata": {
"label": "Block Cloud Metadata Endpoints",
"description": "Block cloud provider metadata endpoints (169.254.169.254)"
}
}
}, },
"connections": { "connections": {
"title": "Connections", "title": "Connections",
@@ -3690,146 +3792,254 @@
"disconnect": "Disconnect", "disconnect": "Disconnect",
"disconnected": "Provider disconnected successfully", "disconnected": "Provider disconnected successfully",
"disconnectError": "Failed to disconnect provider", "disconnectError": "Failed to disconnect provider",
"ssoAutoLogin": "SSO Auto Login", "ssoAutoLogin": {
"ssoAutoLogin.enable": "Enable SSO Auto Login", "label": "SSO Auto Login",
"ssoAutoLogin.description": "Automatically redirect to SSO login when authentication is required", "enable": "Enable SSO Auto Login",
"oauth2": "OAuth2", "description": "Automatically redirect to SSO login when authentication is required"
"oauth2.enabled": "Enable OAuth2", },
"oauth2.enabled.description": "Allow users to authenticate using OAuth2 providers", "oauth2": {
"oauth2.provider": "Provider", "label": "OAuth2",
"oauth2.provider.description": "The OAuth2 provider to use for authentication", "enabled": {
"oauth2.issuer": "Issuer URL", "label": "Enable OAuth2",
"oauth2.issuer.description": "The OAuth2 provider issuer URL", "description": "Allow users to authenticate using OAuth2 providers"
"oauth2.clientId": "Client ID", },
"oauth2.clientId.description": "The OAuth2 client ID from your provider", "provider": {
"oauth2.clientSecret": "Client Secret", "label": "Provider",
"oauth2.clientSecret.description": "The OAuth2 client secret from your provider", "description": "The OAuth2 provider to use for authentication"
"oauth2.useAsUsername": "Use as Username", },
"oauth2.useAsUsername.description": "The OAuth2 claim to use as the username (e.g., email, sub)", "issuer": {
"oauth2.autoCreateUser": "Auto Create Users", "label": "Issuer URL",
"oauth2.autoCreateUser.description": "Automatically create user accounts on first OAuth2 login", "description": "The OAuth2 provider issuer URL"
"oauth2.blockRegistration": "Block Registration", },
"oauth2.blockRegistration.description": "Prevent new user registration via OAuth2", "clientId": {
"oauth2.scopes": "OAuth2 Scopes", "label": "Client ID",
"oauth2.scopes.description": "Comma-separated list of OAuth2 scopes to request (e.g., openid, profile, email)", "description": "The OAuth2 client ID from your provider"
"saml2": "SAML2", },
"saml2.enabled": "Enable SAML2", "clientSecret": {
"saml2.enabled.description": "Allow users to authenticate using SAML2 providers", "label": "Client Secret",
"saml2.provider": "Provider", "description": "The OAuth2 client secret from your provider"
"saml2.provider.description": "The SAML2 provider name", },
"saml2.registrationId": "Registration ID", "useAsUsername": {
"saml2.registrationId.description": "The SAML2 registration identifier", "label": "Use as Username",
"saml2.autoCreateUser": "Auto Create Users", "description": "The OAuth2 claim to use as the username (e.g., email, sub)"
"saml2.autoCreateUser.description": "Automatically create user accounts on first SAML2 login", },
"saml2.blockRegistration": "Block Registration", "autoCreateUser": {
"saml2.blockRegistration.description": "Prevent new user registration via SAML2" "label": "Auto Create Users",
"description": "Automatically create user accounts on first OAuth2 login"
},
"blockRegistration": {
"label": "Block Registration",
"description": "Prevent new user registration via OAuth2"
},
"scopes": {
"label": "OAuth2 Scopes",
"description": "Comma-separated list of OAuth2 scopes to request (e.g., openid, profile, email)"
}
},
"saml2": {
"label": "SAML2",
"enabled": {
"label": "Enable SAML2",
"description": "Allow users to authenticate using SAML2 providers"
},
"provider": {
"label": "Provider",
"description": "The SAML2 provider name"
},
"registrationId": {
"label": "Registration ID",
"description": "The SAML2 registration identifier"
},
"autoCreateUser": {
"label": "Auto Create Users",
"description": "Automatically create user accounts on first SAML2 login"
},
"blockRegistration": {
"label": "Block Registration",
"description": "Prevent new user registration via SAML2"
}
}
}, },
"database": { "database": {
"title": "Database", "title": "Database",
"description": "Configure custom database connection settings for enterprise deployments.", "description": "Configure custom database connection settings for enterprise deployments.",
"configuration": "Database Configuration", "configuration": "Database Configuration",
"enableCustom": "Enable Custom Database", "enableCustom": {
"enableCustom.description": "Use your own custom database configuration instead of the default embedded database", "label": "Enable Custom Database",
"customUrl": "Custom Database URL", "description": "Use your own custom database configuration instead of the default embedded database"
"customUrl.description": "Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.", },
"type": "Database Type", "customUrl": {
"type.description": "Type of database (not used if custom URL is provided)", "label": "Custom Database URL",
"hostName": "Host Name", "description": "Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used."
"hostName.description": "Database server hostname (not used if custom URL is provided)", },
"port": "Port", "type": {
"port.description": "Database server port (not used if custom URL is provided)", "label": "Database Type",
"name": "Database Name", "description": "Type of database (not used if custom URL is provided)"
"name.description": "Name of the database (not used if custom URL is provided)", },
"username": "Username", "hostName": {
"username.description": "Database authentication username", "label": "Host Name",
"password": "Password", "description": "Database server hostname (not used if custom URL is provided)"
"password.description": "Database authentication password" },
"port": {
"label": "Port",
"description": "Database server port (not used if custom URL is provided)"
},
"name": {
"label": "Database Name",
"description": "Name of the database (not used if custom URL is provided)"
},
"username": {
"label": "Username",
"description": "Database authentication username"
},
"password": {
"label": "Password",
"description": "Database authentication password"
}
}, },
"privacy": { "privacy": {
"title": "Privacy", "title": "Privacy",
"description": "Configure privacy and data collection settings.", "description": "Configure privacy and data collection settings.",
"analytics": "Analytics & Tracking", "analytics": "Analytics & Tracking",
"enableAnalytics": "Enable Analytics", "enableAnalytics": {
"enableAnalytics.description": "Collect anonymous usage analytics to help improve the application", "label": "Enable Analytics",
"metricsEnabled": "Enable Metrics", "description": "Collect anonymous usage analytics to help improve the application"
"metricsEnabled.description": "Enable collection of performance and usage metrics. Provides API endpoint for admins to access metrics data", },
"metricsEnabled": {
"label": "Enable Metrics",
"description": "Enable collection of performance and usage metrics. Provides API endpoint for admins to access metrics data"
},
"searchEngine": "Search Engine Visibility", "searchEngine": "Search Engine Visibility",
"googleVisibility": "Google Visibility", "googleVisibility": {
"googleVisibility.description": "Allow search engines to index this application" "label": "Google Visibility",
"description": "Allow search engines to index this application"
}
}, },
"advanced": { "advanced": {
"title": "Advanced", "title": "Advanced",
"description": "Configure advanced features and experimental functionality.", "description": "Configure advanced features and experimental functionality.",
"features": "Feature Flags", "features": "Feature Flags",
"processing": "Processing", "processing": "Processing",
"endpoints": "Endpoints", "endpoints": {
"endpoints.manage": "Manage API Endpoints", "label": "Endpoints",
"endpoints.description": "Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints.", "manage": "Manage API Endpoints",
"enableAlphaFunctionality": "Enable Alpha Features", "description": "Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints."
"enableAlphaFunctionality.description": "Enable experimental and alpha-stage features (may be unstable)", },
"enableUrlToPDF": "Enable URL to PDF", "enableAlphaFunctionality": {
"enableUrlToPDF.description": "Allow conversion of web pages to PDF documents", "label": "Enable Alpha Features",
"maxDPI": "Maximum DPI", "description": "Enable experimental and alpha-stage features (may be unstable)"
"maxDPI.description": "Maximum DPI for image processing (0 = unlimited)", },
"tessdataDir": "Tessdata Directory", "enableUrlToPDF": {
"tessdataDir.description": "Path to the tessdata directory for OCR language files", "label": "Enable URL to PDF",
"disableSanitize": "Disable HTML Sanitization", "description": "Allow conversion of web pages to PDF documents"
"disableSanitize.description": "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities", },
"tempFileManagement": "Temp File Management", "maxDPI": {
"tempFileManagement.description": "Configure temporary file storage and cleanup behavior", "label": "Maximum DPI",
"tempFileManagement.baseTmpDir": "Base Temp Directory", "description": "Maximum DPI for image processing (0 = unlimited)"
"tempFileManagement.baseTmpDir.description": "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)", },
"tempFileManagement.libreofficeDir": "LibreOffice Temp Directory", "tessdataDir": {
"tempFileManagement.libreofficeDir.description": "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)", "label": "Tessdata Directory",
"tempFileManagement.systemTempDir": "System Temp Directory", "description": "Path to the tessdata directory for OCR language files"
"tempFileManagement.systemTempDir.description": "System temp directory to clean (only used if cleanupSystemTemp is enabled)", },
"tempFileManagement.prefix": "Temp File Prefix", "disableSanitize": {
"tempFileManagement.prefix.description": "Prefix for temp file names", "label": "Disable HTML Sanitization",
"tempFileManagement.maxAgeHours": "Max Age (hours)", "description": "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities"
"tempFileManagement.maxAgeHours.description": "Maximum age in hours before temp files are cleaned up", },
"tempFileManagement.cleanupIntervalMinutes": "Cleanup Interval (minutes)", "tempFileManagement": {
"tempFileManagement.cleanupIntervalMinutes.description": "How often to run cleanup (in minutes)", "label": "Temp File Management",
"tempFileManagement.startupCleanup": "Startup Cleanup", "description": "Configure temporary file storage and cleanup behavior",
"tempFileManagement.startupCleanup.description": "Clean up old temp files on application startup", "baseTmpDir": {
"tempFileManagement.cleanupSystemTemp": "Cleanup System Temp", "label": "Base Temp Directory",
"tempFileManagement.cleanupSystemTemp.description": "Whether to clean broader system temp directory (use with caution)", "description": "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)"
"processExecutor": "Process Executor Limits", },
"processExecutor.description": "Configure session limits and timeouts for each process executor", "libreofficeDir": {
"processExecutor.sessionLimit": "Session Limit", "label": "LibreOffice Temp Directory",
"processExecutor.sessionLimit.description": "Maximum concurrent instances", "description": "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)"
"processExecutor.timeout": "Timeout (minutes)", },
"processExecutor.timeout.description": "Maximum execution time", "systemTempDir": {
"processExecutor.libreOffice": "LibreOffice", "label": "System Temp Directory",
"processExecutor.pdfToHtml": "PDF to HTML", "description": "System temp directory to clean (only used if cleanupSystemTemp is enabled)"
"processExecutor.qpdf": "QPDF", },
"processExecutor.tesseract": "Tesseract OCR", "prefix": {
"processExecutor.pythonOpenCv": "Python OpenCV", "label": "Temp File Prefix",
"processExecutor.weasyPrint": "WeasyPrint", "description": "Prefix for temp file names"
"processExecutor.installApp": "Install App", },
"processExecutor.calibre": "Calibre", "maxAgeHours": {
"processExecutor.ghostscript": "Ghostscript", "label": "Max Age (hours)",
"processExecutor.ocrMyPdf": "OCRmyPDF" "description": "Maximum age in hours before temp files are cleaned up"
},
"cleanupIntervalMinutes": {
"label": "Cleanup Interval (minutes)",
"description": "How often to run cleanup (in minutes)"
},
"startupCleanup": {
"label": "Startup Cleanup",
"description": "Clean up old temp files on application startup"
},
"cleanupSystemTemp": {
"label": "Cleanup System Temp",
"description": "Whether to clean broader system temp directory (use with caution)"
}
},
"processExecutor": {
"label": "Process Executor Limits",
"description": "Configure session limits and timeouts for each process executor",
"sessionLimit": {
"label": "Session Limit",
"description": "Maximum concurrent instances"
},
"timeout": {
"label": "Timeout (minutes)",
"description": "Maximum execution time"
},
"libreOffice": "LibreOffice",
"pdfToHtml": "PDF to HTML",
"qpdf": "QPDF",
"tesseract": "Tesseract OCR",
"pythonOpenCv": "Python OpenCV",
"weasyPrint": "WeasyPrint",
"installApp": "Install App",
"calibre": "Calibre",
"ghostscript": "Ghostscript",
"ocrMyPdf": "OCRmyPDF"
}
}, },
"mail": { "mail": {
"title": "Mail Server", "title": "Mail Server",
"description": "Configure SMTP settings for sending email notifications.", "description": "Configure SMTP settings for sending email notifications.",
"smtp": "SMTP Configuration", "smtp": "SMTP Configuration",
"enabled": "Enable Mail", "enabled": {
"enabled.description": "Enable email notifications and SMTP functionality", "label": "Enable Mail",
"host": "SMTP Host", "description": "Enable email notifications and SMTP functionality"
"host.description": "The hostname or IP address of your SMTP server", },
"port": "SMTP Port", "host": {
"port.description": "The port number for SMTP connection (typically 25, 465, or 587)", "label": "SMTP Host",
"username": "SMTP Username", "description": "The hostname or IP address of your SMTP server"
"username.description": "Username for SMTP authentication", },
"password": "SMTP Password", "port": {
"password.description": "Password for SMTP authentication", "label": "SMTP Port",
"from": "From Address", "description": "The port number for SMTP connection (typically 25, 465, or 587)"
"from.description": "The email address to use as the sender", },
"enableInvites": "Enable Email Invites", "username": {
"enableInvites.description": "Allow admins to invite users via email with auto-generated passwords", "label": "SMTP Username",
"frontendUrl": "Frontend URL", "description": "Username for SMTP authentication"
"frontendUrl.description": "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL." },
"password": {
"label": "SMTP Password",
"description": "Password for SMTP authentication"
},
"from": {
"label": "From Address",
"description": "The email address to use as the sender"
},
"enableInvites": {
"label": "Enable Email Invites",
"description": "Allow admins to invite users via email with auto-generated passwords"
},
"frontendUrl": {
"label": "Frontend URL",
"description": "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL."
}
}, },
"legal": { "legal": {
"title": "Legal Documents", "title": "Legal Documents",
@@ -3838,25 +4048,39 @@
"title": "Legal Responsibility Warning", "title": "Legal Responsibility Warning",
"message": "By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations." "message": "By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations."
}, },
"termsAndConditions": "Terms and Conditions", "termsAndConditions": {
"termsAndConditions.description": "URL or filename to terms and conditions", "label": "Terms and Conditions",
"privacyPolicy": "Privacy Policy", "description": "URL or filename to terms and conditions"
"privacyPolicy.description": "URL or filename to privacy policy", },
"accessibilityStatement": "Accessibility Statement", "privacyPolicy": {
"accessibilityStatement.description": "URL or filename to accessibility statement", "label": "Privacy Policy",
"cookiePolicy": "Cookie Policy", "description": "URL or filename to privacy policy"
"cookiePolicy.description": "URL or filename to cookie policy", },
"impressum": "Impressum", "accessibilityStatement": {
"impressum.description": "URL or filename to impressum (required in some jurisdictions)" "label": "Accessibility Statement",
"description": "URL or filename to accessibility statement"
},
"cookiePolicy": {
"label": "Cookie Policy",
"description": "URL or filename to cookie policy"
},
"impressum": {
"label": "Impressum",
"description": "URL or filename to impressum (required in some jurisdictions)"
}
}, },
"premium": { "premium": {
"title": "Premium & Enterprise", "title": "Premium & Enterprise",
"description": "Configure your premium or enterprise license key.", "description": "Configure your premium or enterprise license key.",
"license": "License Configuration", "license": "License Configuration",
"key": "License Key", "key": {
"key.description": "Enter your premium or enterprise license key", "label": "License Key",
"enabled": "Enable Premium Features", "description": "Enter your premium or enterprise license key"
"enabled.description": "Enable license key checks for pro/enterprise features", },
"enabled": {
"label": "Enable Premium Features",
"description": "Enable license key checks for pro/enterprise features"
},
"movedFeatures": { "movedFeatures": {
"title": "Premium Features Distributed", "title": "Premium Features Distributed",
"message": "Premium and Enterprise features are now organized in their respective sections:" "message": "Premium and Enterprise features are now organized in their respective sections:"
@@ -3865,25 +4089,39 @@
"features": { "features": {
"title": "Features", "title": "Features",
"description": "Configure optional features and functionality.", "description": "Configure optional features and functionality.",
"serverCertificate": "Server Certificate", "serverCertificate": {
"serverCertificate.description": "Configure server-side certificate generation for \"Sign with Stirling-PDF\" functionality", "label": "Server Certificate",
"serverCertificate.enabled": "Enable Server Certificate", "description": "Configure server-side certificate generation for \"Sign with Stirling-PDF\" functionality",
"serverCertificate.enabled.description": "Enable server-side certificate for \"Sign with Stirling-PDF\" option", "enabled": {
"serverCertificate.organizationName": "Organization Name", "label": "Enable Server Certificate",
"serverCertificate.organizationName.description": "Organization name for generated certificates", "description": "Enable server-side certificate for \"Sign with Stirling-PDF\" option"
"serverCertificate.validity": "Certificate Validity (days)", },
"serverCertificate.validity.description": "Number of days the certificate will be valid", "organizationName": {
"serverCertificate.regenerateOnStartup": "Regenerate on Startup", "label": "Organization Name",
"serverCertificate.regenerateOnStartup.description": "Generate new certificate on each application startup" "description": "Organization name for generated certificates"
},
"validity": {
"label": "Certificate Validity (days)",
"description": "Number of days the certificate will be valid"
},
"regenerateOnStartup": {
"label": "Regenerate on Startup",
"description": "Generate new certificate on each application startup"
}
}
}, },
"endpoints": { "endpoints": {
"title": "API Endpoints", "title": "API Endpoints",
"description": "Control which API endpoints and endpoint groups are available.", "description": "Control which API endpoints and endpoint groups are available.",
"management": "Endpoint Management", "management": "Endpoint Management",
"toRemove": "Disabled Endpoints", "toRemove": {
"toRemove.description": "Select individual endpoints to disable", "label": "Disabled Endpoints",
"groupsToRemove": "Disabled Endpoint Groups", "description": "Select individual endpoints to disable"
"groupsToRemove.description": "Select endpoint groups to disable", },
"groupsToRemove": {
"label": "Disabled Endpoint Groups",
"description": "Select endpoint groups to disable"
},
"note": "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect." "note": "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect."
} }
} }
@@ -3994,8 +4232,10 @@
"desc": "Remove potentially harmful elements from PDF files.", "desc": "Remove potentially harmful elements from PDF files.",
"submit": "Sanitise PDF", "submit": "Sanitise PDF",
"completed": "Sanitisation completed successfully", "completed": "Sanitisation completed successfully",
"error.generic": "Sanitisation failed", "error": {
"error.failed": "An error occurred while sanitising the PDF.", "generic": "Sanitisation failed",
"failed": "An error occurred while sanitising the PDF."
},
"filenamePrefix": "sanitised", "filenamePrefix": "sanitised",
"sanitizationResults": "Sanitisation Results", "sanitizationResults": "Sanitisation Results",
"steps": { "steps": {
@@ -4009,18 +4249,30 @@
"options": { "options": {
"title": "Sanitisation Options", "title": "Sanitisation Options",
"note": "Select the elements you want to remove from the PDF. At least one option must be selected.", "note": "Select the elements you want to remove from the PDF. At least one option must be selected.",
"removeJavaScript": "Remove JavaScript", "removeJavaScript": {
"removeJavaScript.desc": "Remove JavaScript actions and scripts from the PDF", "label": "Remove JavaScript",
"removeEmbeddedFiles": "Remove Embedded Files", "desc": "Remove JavaScript actions and scripts from the PDF"
"removeEmbeddedFiles.desc": "Remove any files embedded within the PDF", },
"removeXMPMetadata": "Remove XMP Metadata", "removeEmbeddedFiles": {
"removeXMPMetadata.desc": "Remove XMP metadata from the PDF", "label": "Remove Embedded Files",
"removeMetadata": "Remove Document Metadata", "desc": "Remove any files embedded within the PDF"
"removeMetadata.desc": "Remove document information metadata (title, author, etc.)", },
"removeLinks": "Remove Links", "removeXMPMetadata": {
"removeLinks.desc": "Remove external links and launch actions from the PDF", "label": "Remove XMP Metadata",
"removeFonts": "Remove Fonts", "desc": "Remove XMP metadata from the PDF"
"removeFonts.desc": "Remove embedded fonts from the PDF" },
"removeMetadata": {
"label": "Remove Document Metadata",
"desc": "Remove document information metadata (title, author, etc.)"
},
"removeLinks": {
"label": "Remove Links",
"desc": "Remove external links and launch actions from the PDF"
},
"removeFonts": {
"label": "Remove Fonts",
"desc": "Remove embedded fonts from the PDF"
}
} }
}, },
"addPassword": { "addPassword": {
@@ -4389,34 +4641,6 @@
"undoStorageError": "Undo completed but some files could not be saved to storage", "undoStorageError": "Undo completed but some files could not be saved to storage",
"undoSuccess": "Operation undone successfully", "undoSuccess": "Operation undone successfully",
"unsupported": "Unsupported", "unsupported": "Unsupported",
"signup": {
"title": "Create an account",
"subtitle": "Join Stirling PDF to get started",
"name": "Name",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm password",
"enterName": "Enter your name",
"enterEmail": "Enter your email",
"enterPassword": "Enter your password",
"confirmPasswordPlaceholder": "Confirm password",
"or": "or",
"creatingAccount": "Creating Account...",
"signUp": "Sign Up",
"alreadyHaveAccount": "Already have an account? Sign in",
"pleaseFillAllFields": "Please fill in all fields",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters long",
"invalidEmail": "Please enter a valid email address",
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
"unexpectedError": "Unexpected error: {{message}}",
"useEmailInstead": "Use Email Instead",
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"passwordRequired": "Password is required",
"confirmPasswordRequired": "Please confirm your password"
},
"onboarding": { "onboarding": {
"welcomeModal": { "welcomeModal": {
"title": "Welcome to Stirling PDF!", "title": "Welcome to Stirling PDF!",
@@ -4458,8 +4682,10 @@
"loading": "Loading people...", "loading": "Loading people...",
"searchMembers": "Search members...", "searchMembers": "Search members...",
"addMembers": "Add Members", "addMembers": "Add Members",
"inviteMembers": "Invite Members", "inviteMembers": {
"inviteMembers.subtitle": "Type or paste in emails below, separated by commas. Your workspace will be billed by members.", "label": "Invite Members",
"subtitle": "Type or paste in emails below, separated by commas. Your workspace will be billed by members."
},
"user": "User", "user": "User",
"role": "Role", "role": "Role",
"team": "Team", "team": "Team",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -205,7 +205,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality', 'Enable Alpha Features')}</Text> <Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality.label', 'Enable Alpha Features')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')} {t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')}
</Text> </Text>
@@ -221,7 +221,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF', 'Enable URL to PDF')}</Text> <Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF.label', 'Enable URL to PDF')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')} {t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
</Text> </Text>
@@ -237,7 +237,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize', 'Disable HTML Sanitization')}</Text> <Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize.label', 'Disable HTML Sanitization')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')} {t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
</Text> </Text>
@@ -262,7 +262,7 @@ export default function AdminAdvancedSection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.advanced.maxDPI', 'Maximum DPI')}</span> <span>{t('admin.settings.advanced.maxDPI.label', 'Maximum DPI')}</span>
<PendingBadge show={isFieldPending('maxDPI')} /> <PendingBadge show={isFieldPending('maxDPI')} />
</Group> </Group>
} }
@@ -278,7 +278,7 @@ export default function AdminAdvancedSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}</span> <span>{t('admin.settings.advanced.tessdataDir.label', 'Tessdata Directory')}</span>
<PendingBadge show={isFieldPending('tessdataDir')} /> <PendingBadge show={isFieldPending('tessdataDir')} />
</Group> </Group>
} }
@@ -295,7 +295,7 @@ export default function AdminAdvancedSection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<div> <div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement', 'Temp File Management')}</Text> <Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement.label', 'Temp File Management')}</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t('admin.settings.advanced.tempFileManagement.description', 'Configure temporary file storage and cleanup behavior')} {t('admin.settings.advanced.tempFileManagement.description', 'Configure temporary file storage and cleanup behavior')}
</Text> </Text>
@@ -303,7 +303,7 @@ export default function AdminAdvancedSection() {
<div> <div>
<TextInput <TextInput
label={t('admin.settings.advanced.tempFileManagement.baseTmpDir', 'Base Temp Directory')} label={t('admin.settings.advanced.tempFileManagement.baseTmpDir.label', 'Base Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.baseTmpDir.description', 'Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)')} description={t('admin.settings.advanced.tempFileManagement.baseTmpDir.description', 'Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)')}
value={settings.tempFileManagement?.baseTmpDir || ''} value={settings.tempFileManagement?.baseTmpDir || ''}
onChange={(e) => setSettings({ onChange={(e) => setSettings({
@@ -316,7 +316,7 @@ export default function AdminAdvancedSection() {
<div> <div>
<TextInput <TextInput
label={t('admin.settings.advanced.tempFileManagement.libreofficeDir', 'LibreOffice Temp Directory')} label={t('admin.settings.advanced.tempFileManagement.libreofficeDir.label', 'LibreOffice Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.libreofficeDir.description', 'Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)')} description={t('admin.settings.advanced.tempFileManagement.libreofficeDir.description', 'Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)')}
value={settings.tempFileManagement?.libreofficeDir || ''} value={settings.tempFileManagement?.libreofficeDir || ''}
onChange={(e) => setSettings({ onChange={(e) => setSettings({
@@ -329,7 +329,7 @@ export default function AdminAdvancedSection() {
<div> <div>
<TextInput <TextInput
label={t('admin.settings.advanced.tempFileManagement.systemTempDir', 'System Temp Directory')} label={t('admin.settings.advanced.tempFileManagement.systemTempDir.label', 'System Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.systemTempDir.description', 'System temp directory to clean (only used if cleanupSystemTemp is enabled)')} description={t('admin.settings.advanced.tempFileManagement.systemTempDir.description', 'System temp directory to clean (only used if cleanupSystemTemp is enabled)')}
value={settings.tempFileManagement?.systemTempDir || ''} value={settings.tempFileManagement?.systemTempDir || ''}
onChange={(e) => setSettings({ onChange={(e) => setSettings({
@@ -342,7 +342,7 @@ export default function AdminAdvancedSection() {
<div> <div>
<TextInput <TextInput
label={t('admin.settings.advanced.tempFileManagement.prefix', 'Temp File Prefix')} label={t('admin.settings.advanced.tempFileManagement.prefix.label', 'Temp File Prefix')}
description={t('admin.settings.advanced.tempFileManagement.prefix.description', 'Prefix for temp file names')} description={t('admin.settings.advanced.tempFileManagement.prefix.description', 'Prefix for temp file names')}
value={settings.tempFileManagement?.prefix || 'stirling-pdf-'} value={settings.tempFileManagement?.prefix || 'stirling-pdf-'}
onChange={(e) => setSettings({ onChange={(e) => setSettings({
@@ -355,7 +355,7 @@ export default function AdminAdvancedSection() {
<div> <div>
<NumberInput <NumberInput
label={t('admin.settings.advanced.tempFileManagement.maxAgeHours', 'Max Age (hours)')} label={t('admin.settings.advanced.tempFileManagement.maxAgeHours.label', 'Max Age (hours)')}
description={t('admin.settings.advanced.tempFileManagement.maxAgeHours.description', 'Maximum age in hours before temp files are cleaned up')} description={t('admin.settings.advanced.tempFileManagement.maxAgeHours.description', 'Maximum age in hours before temp files are cleaned up')}
value={settings.tempFileManagement?.maxAgeHours ?? 24} value={settings.tempFileManagement?.maxAgeHours ?? 24}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -369,7 +369,7 @@ export default function AdminAdvancedSection() {
<div> <div>
<NumberInput <NumberInput
label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes', 'Cleanup Interval (minutes)')} label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.label', 'Cleanup Interval (minutes)')}
description={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.description', 'How often to run cleanup (in minutes)')} description={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.description', 'How often to run cleanup (in minutes)')}
value={settings.tempFileManagement?.cleanupIntervalMinutes ?? 30} value={settings.tempFileManagement?.cleanupIntervalMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -383,7 +383,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup', 'Startup Cleanup')}</Text> <Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup.label', 'Startup Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')} {t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')}
</Text> </Text>
@@ -402,7 +402,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp', 'Cleanup System Temp')}</Text> <Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label', 'Cleanup System Temp')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')} {t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')}
</Text> </Text>
@@ -424,7 +424,7 @@ export default function AdminAdvancedSection() {
{/* Process Executor Limits */} {/* Process Executor Limits */}
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor', 'Process Executor Limits')}</Text> <Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor.label', 'Process Executor Limits')}</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t('admin.settings.advanced.processExecutor.description', 'Configure session limits and timeouts for each process executor')} {t('admin.settings.advanced.processExecutor.description', 'Configure session limits and timeouts for each process executor')}
</Text> </Text>
@@ -436,7 +436,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.libreOfficeSessionLimit ?? 1} value={settings.processExecutor?.sessionLimit?.libreOfficeSessionLimit ?? 1}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -450,7 +450,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.libreOfficetimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.libreOfficetimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -473,7 +473,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pdfToHtmlSessionLimit ?? 1} value={settings.processExecutor?.sessionLimit?.pdfToHtmlSessionLimit ?? 1}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -487,7 +487,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pdfToHtmltimeoutMinutes ?? 20} value={settings.processExecutor?.timeoutMinutes?.pdfToHtmltimeoutMinutes ?? 20}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -510,7 +510,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.qpdfSessionLimit ?? 4} value={settings.processExecutor?.sessionLimit?.qpdfSessionLimit ?? 4}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -524,7 +524,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.qpdfTimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.qpdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -547,7 +547,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.tesseractSessionLimit ?? 1} value={settings.processExecutor?.sessionLimit?.tesseractSessionLimit ?? 1}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -561,7 +561,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.tesseractTimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.tesseractTimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -584,7 +584,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pythonOpenCvSessionLimit ?? 8} value={settings.processExecutor?.sessionLimit?.pythonOpenCvSessionLimit ?? 8}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -598,7 +598,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pythonOpenCvtimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.pythonOpenCvtimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -621,7 +621,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.weasyPrintSessionLimit ?? 16} value={settings.processExecutor?.sessionLimit?.weasyPrintSessionLimit ?? 16}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -635,7 +635,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.weasyPrinttimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.weasyPrinttimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -658,7 +658,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.installAppSessionLimit ?? 1} value={settings.processExecutor?.sessionLimit?.installAppSessionLimit ?? 1}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -672,7 +672,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.installApptimeoutMinutes ?? 60} value={settings.processExecutor?.timeoutMinutes?.installApptimeoutMinutes ?? 60}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -695,7 +695,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.calibreSessionLimit ?? 1} value={settings.processExecutor?.sessionLimit?.calibreSessionLimit ?? 1}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -709,7 +709,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.calibretimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.calibretimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -732,7 +732,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ghostscriptSessionLimit ?? 8} value={settings.processExecutor?.sessionLimit?.ghostscriptSessionLimit ?? 8}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -746,7 +746,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ghostscriptTimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.ghostscriptTimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -769,7 +769,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel> <Accordion.Panel>
<Stack gap="sm"> <Stack gap="sm">
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')} label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')} description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ocrMyPdfSessionLimit ?? 2} value={settings.processExecutor?.sessionLimit?.ocrMyPdfSessionLimit ?? 2}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -783,7 +783,7 @@ export default function AdminAdvancedSection() {
max={100} max={100}
/> />
<NumberInput <NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')} label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')} description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ocrMyPdfTimeoutMinutes ?? 30} value={settings.processExecutor?.timeoutMinutes?.ocrMyPdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({ onChange={(value) => setSettings({
@@ -328,7 +328,7 @@ export default function AdminConnectionsSection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin', 'SSO Auto Login')}</Text> <Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
<Badge color="yellow" size="sm">PRO</Badge> <Badge color="yellow" size="sm">PRO</Badge>
</Group> </Group>
@@ -123,7 +123,7 @@ export default function AdminDatabaseSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom', 'Enable Custom Database')}</Text> <Text fw={500} size="sm">{t('admin.settings.database.enableCustom.label', 'Enable Custom Database')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')} {t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')}
</Text> </Text>
@@ -143,7 +143,7 @@ export default function AdminDatabaseSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.customUrl', 'Custom Database URL')}</span> <span>{t('admin.settings.database.customUrl.label', 'Custom Database URL')}</span>
<PendingBadge show={isFieldPending('customDatabaseUrl')} /> <PendingBadge show={isFieldPending('customDatabaseUrl')} />
</Group> </Group>
} }
@@ -158,7 +158,7 @@ export default function AdminDatabaseSection() {
<Select <Select
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.type', 'Database Type')}</span> <span>{t('admin.settings.database.type.label', 'Database Type')}</span>
<PendingBadge show={isFieldPending('type')} /> <PendingBadge show={isFieldPending('type')} />
</Group> </Group>
} }
@@ -178,7 +178,7 @@ export default function AdminDatabaseSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.hostName', 'Host Name')}</span> <span>{t('admin.settings.database.hostName.label', 'Host Name')}</span>
<PendingBadge show={isFieldPending('hostName')} /> <PendingBadge show={isFieldPending('hostName')} />
</Group> </Group>
} }
@@ -193,7 +193,7 @@ export default function AdminDatabaseSection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.port', 'Port')}</span> <span>{t('admin.settings.database.port.label', 'Port')}</span>
<PendingBadge show={isFieldPending('port')} /> <PendingBadge show={isFieldPending('port')} />
</Group> </Group>
} }
@@ -209,7 +209,7 @@ export default function AdminDatabaseSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.name', 'Database Name')}</span> <span>{t('admin.settings.database.name.label', 'Database Name')}</span>
<PendingBadge show={isFieldPending('name')} /> <PendingBadge show={isFieldPending('name')} />
</Group> </Group>
} }
@@ -224,7 +224,7 @@ export default function AdminDatabaseSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.username', 'Username')}</span> <span>{t('admin.settings.database.username.label', 'Username')}</span>
<PendingBadge show={isFieldPending('username')} /> <PendingBadge show={isFieldPending('username')} />
</Group> </Group>
} }
@@ -239,7 +239,7 @@ export default function AdminDatabaseSection() {
<PasswordInput <PasswordInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.database.password', 'Password')}</span> <span>{t('admin.settings.database.password.label', 'Password')}</span>
<PendingBadge show={isFieldPending('password')} /> <PendingBadge show={isFieldPending('password')} />
</Group> </Group>
} }
@@ -117,7 +117,7 @@ export default function AdminEndpointsSection() {
<MultiSelect <MultiSelect
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}</span> <span>{t('admin.settings.endpoints.toRemove.label', 'Disabled Endpoints')}</span>
<PendingBadge show={isFieldPending('toRemove')} /> <PendingBadge show={isFieldPending('toRemove')} />
</Group> </Group>
} }
@@ -136,7 +136,7 @@ export default function AdminEndpointsSection() {
<MultiSelect <MultiSelect
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}</span> <span>{t('admin.settings.endpoints.groupsToRemove.label', 'Disabled Endpoint Groups')}</span>
<PendingBadge show={isFieldPending('groupsToRemove')} /> <PendingBadge show={isFieldPending('groupsToRemove')} />
</Group> </Group>
} }
@@ -106,7 +106,7 @@ export default function AdminFeaturesSection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate', 'Server Certificate')}</Text> <Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
<Badge color="blue" size="sm">PRO</Badge> <Badge color="blue" size="sm">PRO</Badge>
</Group> </Group>
@@ -116,7 +116,7 @@ export default function AdminFeaturesSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled', 'Enable Server Certificate')}</Text> <Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled.label', 'Enable Server Certificate')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')} {t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')}
</Text> </Text>
@@ -137,7 +137,7 @@ export default function AdminFeaturesSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}</span> <span>{t('admin.settings.features.serverCertificate.organizationName.label', 'Organization Name')}</span>
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} /> <PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
</Group> </Group>
} }
@@ -155,7 +155,7 @@ export default function AdminFeaturesSection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}</span> <span>{t('admin.settings.features.serverCertificate.validity.label', 'Certificate Validity (days)')}</span>
<PendingBadge show={isFieldPending('serverCertificate.validity')} /> <PendingBadge show={isFieldPending('serverCertificate.validity')} />
</Group> </Group>
} }
@@ -172,7 +172,7 @@ export default function AdminFeaturesSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup', 'Regenerate on Startup')}</Text> <Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup.label', 'Regenerate on Startup')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')} {t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
</Text> </Text>
@@ -180,7 +180,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.appNameNavbar', 'Navbar Brand')}</span> <span>{t('admin.settings.general.appNameNavbar.label', 'Navbar Brand')}</span>
<PendingBadge show={isFieldPending('ui.appNameNavbar')} /> <PendingBadge show={isFieldPending('ui.appNameNavbar')} />
</Group> </Group>
} }
@@ -195,7 +195,7 @@ export default function AdminGeneralSection() {
<MultiSelect <MultiSelect
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.languages', 'Available Languages')}</span> <span>{t('admin.settings.general.languages.label', 'Available Languages')}</span>
<PendingBadge show={isFieldPending('ui.languages')} /> <PendingBadge show={isFieldPending('ui.languages')} />
</Group> </Group>
} }
@@ -225,7 +225,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.defaultLocale', 'Default Locale')}</span> <span>{t('admin.settings.general.defaultLocale.label', 'Default Locale')}</span>
<PendingBadge show={isFieldPending('system.defaultLocale')} /> <PendingBadge show={isFieldPending('system.defaultLocale')} />
</Group> </Group>
} }
@@ -240,7 +240,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.fileUploadLimit', 'File Upload Limit')}</span> <span>{t('admin.settings.general.fileUploadLimit.label', 'File Upload Limit')}</span>
<PendingBadge show={isFieldPending('system.fileUploadLimit')} /> <PendingBadge show={isFieldPending('system.fileUploadLimit')} />
</Group> </Group>
} }
@@ -253,7 +253,7 @@ export default function AdminGeneralSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate', 'Show Update Notifications')}</Text> <Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')} {t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')}
</Text> </Text>
@@ -269,7 +269,7 @@ export default function AdminGeneralSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin', 'Show Updates to Admins Only')}</Text> <Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin.label', 'Show Updates to Admins Only')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')} {t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')}
</Text> </Text>
@@ -285,7 +285,7 @@ export default function AdminGeneralSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles', 'Custom HTML Files')}</Text> <Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles.label', 'Custom HTML Files')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')} {t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')}
</Text> </Text>
@@ -305,13 +305,13 @@ export default function AdminGeneralSection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata', 'Custom Metadata')}</Text> <Text fw={600} size="sm">{t('admin.settings.general.customMetadata.label', 'Custom Metadata')}</Text>
<Badge color="yellow" size="sm">PRO</Badge> <Badge color="yellow" size="sm">PRO</Badge>
</Group> </Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate', 'Auto Update Metadata')}</Text> <Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate.label', 'Auto Update Metadata')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customMetadata.autoUpdate.description', 'Automatically update PDF metadata on all processed documents')} {t('admin.settings.general.customMetadata.autoUpdate.description', 'Automatically update PDF metadata on all processed documents')}
</Text> </Text>
@@ -335,7 +335,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customMetadata.author', 'Default Author')}</span> <span>{t('admin.settings.general.customMetadata.author.label', 'Default Author')}</span>
<PendingBadge show={isFieldPending('customMetadata.author')} /> <PendingBadge show={isFieldPending('customMetadata.author')} />
</Group> </Group>
} }
@@ -356,7 +356,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customMetadata.creator', 'Default Creator')}</span> <span>{t('admin.settings.general.customMetadata.creator.label', 'Default Creator')}</span>
<PendingBadge show={isFieldPending('customMetadata.creator')} /> <PendingBadge show={isFieldPending('customMetadata.creator')} />
</Group> </Group>
} }
@@ -377,7 +377,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customMetadata.producer', 'Default Producer')}</span> <span>{t('admin.settings.general.customMetadata.producer.label', 'Default Producer')}</span>
<PendingBadge show={isFieldPending('customMetadata.producer')} /> <PendingBadge show={isFieldPending('customMetadata.producer')} />
</Group> </Group>
} }
@@ -400,19 +400,19 @@ export default function AdminGeneralSection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<div> <div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths', 'Custom Paths')}</Text> <Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths.label', 'Custom Paths')}</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t('admin.settings.general.customPaths.description', 'Configure custom file system paths for pipeline processing and external tools')} {t('admin.settings.general.customPaths.description', 'Configure custom file system paths for pipeline processing and external tools')}
</Text> </Text>
</div> </div>
<Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline', 'Pipeline Directories')}</Text> <Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline.label', 'Pipeline Directories')}</Text>
<div> <div>
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir', 'Watched Folders Directory')}</span> <span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.label', 'Watched Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.watchedFoldersDir')} /> <PendingBadge show={isFieldPending('customPaths.pipeline.watchedFoldersDir')} />
</Group> </Group>
} }
@@ -436,7 +436,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir', 'Finished Folders Directory')}</span> <span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir.label', 'Finished Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.finishedFoldersDir')} /> <PendingBadge show={isFieldPending('customPaths.pipeline.finishedFoldersDir')} />
</Group> </Group>
} }
@@ -456,13 +456,13 @@ export default function AdminGeneralSection() {
/> />
</div> </div>
<Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations', 'External Tool Paths')}</Text> <Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations.label', 'External Tool Paths')}</Text>
<div> <div>
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.weasyprint', 'WeasyPrint Executable')}</span> <span>{t('admin.settings.general.customPaths.operations.weasyprint.label', 'WeasyPrint Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.weasyprint')} /> <PendingBadge show={isFieldPending('customPaths.operations.weasyprint')} />
</Group> </Group>
} }
@@ -486,7 +486,7 @@ export default function AdminGeneralSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.unoconvert', 'Unoconvert Executable')}</span> <span>{t('admin.settings.general.customPaths.operations.unoconvert.label', 'Unoconvert Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.unoconvert')} /> <PendingBadge show={isFieldPending('customPaths.operations.unoconvert')} />
</Group> </Group>
} }
@@ -87,7 +87,7 @@ export default function AdminLegalSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}</span> <span>{t('admin.settings.legal.termsAndConditions.label', 'Terms and Conditions')}</span>
<PendingBadge show={isFieldPending('termsAndConditions')} /> <PendingBadge show={isFieldPending('termsAndConditions')} />
</Group> </Group>
} }
@@ -102,7 +102,7 @@ export default function AdminLegalSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}</span> <span>{t('admin.settings.legal.privacyPolicy.label', 'Privacy Policy')}</span>
<PendingBadge show={isFieldPending('privacyPolicy')} /> <PendingBadge show={isFieldPending('privacyPolicy')} />
</Group> </Group>
} }
@@ -117,7 +117,7 @@ export default function AdminLegalSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}</span> <span>{t('admin.settings.legal.accessibilityStatement.label', 'Accessibility Statement')}</span>
<PendingBadge show={isFieldPending('accessibilityStatement')} /> <PendingBadge show={isFieldPending('accessibilityStatement')} />
</Group> </Group>
} }
@@ -132,7 +132,7 @@ export default function AdminLegalSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}</span> <span>{t('admin.settings.legal.cookiePolicy.label', 'Cookie Policy')}</span>
<PendingBadge show={isFieldPending('cookiePolicy')} /> <PendingBadge show={isFieldPending('cookiePolicy')} />
</Group> </Group>
} }
@@ -147,7 +147,7 @@ export default function AdminLegalSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.legal.impressum', 'Impressum')}</span> <span>{t('admin.settings.legal.impressum.label', 'Impressum')}</span>
<PendingBadge show={isFieldPending('impressum')} /> <PendingBadge show={isFieldPending('impressum')} />
</Group> </Group>
} }
@@ -122,7 +122,7 @@ export default function AdminMailSection() {
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled', 'Enable Mail')}</Text> <Text fw={500} size="sm">{t('admin.settings.mail.enabled.label', 'Enable Mail')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')} {t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
</Text> </Text>
@@ -138,7 +138,7 @@ export default function AdminMailSection() {
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites', 'Enable Email Invites')}</Text> <Text fw={500} size="sm">{t('admin.settings.mail.enableInvites.label', 'Enable Email Invites')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')} {t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text> </Text>
@@ -157,7 +157,7 @@ export default function AdminMailSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.mail.host', 'SMTP Host')}</span> <span>{t('admin.settings.mail.host.label', 'SMTP Host')}</span>
<PendingBadge show={isFieldPending('host')} /> <PendingBadge show={isFieldPending('host')} />
</Group> </Group>
} }
@@ -172,7 +172,7 @@ export default function AdminMailSection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.mail.port', 'SMTP Port')}</span> <span>{t('admin.settings.mail.port.label', 'SMTP Port')}</span>
<PendingBadge show={isFieldPending('port')} /> <PendingBadge show={isFieldPending('port')} />
</Group> </Group>
} }
@@ -188,7 +188,7 @@ export default function AdminMailSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.mail.username', 'SMTP Username')}</span> <span>{t('admin.settings.mail.username.label', 'SMTP Username')}</span>
<PendingBadge show={isFieldPending('username')} /> <PendingBadge show={isFieldPending('username')} />
</Group> </Group>
} }
@@ -202,7 +202,7 @@ export default function AdminMailSection() {
<PasswordInput <PasswordInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.mail.password', 'SMTP Password')}</span> <span>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} /> <PendingBadge show={isFieldPending('password')} />
</Group> </Group>
} }
@@ -216,7 +216,7 @@ export default function AdminMailSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.mail.from', 'From Address')}</span> <span>{t('admin.settings.mail.from.label', 'From Address')}</span>
<PendingBadge show={isFieldPending('from')} /> <PendingBadge show={isFieldPending('from')} />
</Group> </Group>
} }
@@ -231,7 +231,7 @@ export default function AdminMailSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.mail.frontendUrl', 'Frontend URL')}</span> <span>{t('admin.settings.mail.frontendUrl.label', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('frontendUrl')} /> <PendingBadge show={isFieldPending('frontendUrl')} />
</Group> </Group>
} }
@@ -90,7 +90,7 @@ export default function AdminPremiumSection() {
<TextInput <TextInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.premium.key', 'License Key')}</span> <span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} /> <PendingBadge show={isFieldPending('key')} />
</Group> </Group>
} }
@@ -103,7 +103,7 @@ export default function AdminPremiumSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled', 'Enable Premium Features')}</Text> <Text fw={500} size="sm">{t('admin.settings.premium.enabled.label', 'Enable Premium Features')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')} {t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
</Text> </Text>
@@ -116,7 +116,7 @@ export default function AdminPrivacySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics', 'Enable Analytics')}</Text> <Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics.label', 'Enable Analytics')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')} {t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
</Text> </Text>
@@ -132,7 +132,7 @@ export default function AdminPrivacySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled', 'Enable Metrics')}</Text> <Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled.label', 'Enable Metrics')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')} {t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
</Text> </Text>
@@ -155,7 +155,7 @@ export default function AdminPrivacySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility', 'Google Visibility')}</Text> <Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility.label', 'Google Visibility')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')} {t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
</Text> </Text>
@@ -197,7 +197,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin', 'Enable Login')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.enableLogin.label', 'Enable Login')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')} {t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')}
</Text> </Text>
@@ -213,7 +213,7 @@ export default function AdminSecuritySection() {
<div> <div>
<Select <Select
label={t('admin.settings.security.loginMethod', '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' })}
@@ -236,7 +236,7 @@ export default function AdminSecuritySection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}</span> <span>{t('admin.settings.security.loginAttemptCount.label', 'Login Attempt Limit')}</span>
<PendingBadge show={isFieldPending('loginAttemptCount')} /> <PendingBadge show={isFieldPending('loginAttemptCount')} />
</Group> </Group>
} }
@@ -252,7 +252,7 @@ export default function AdminSecuritySection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}</span> <span>{t('admin.settings.security.loginResetTimeMinutes.label', 'Login Reset Time (minutes)')}</span>
<PendingBadge show={isFieldPending('loginResetTimeMinutes')} /> <PendingBadge show={isFieldPending('loginResetTimeMinutes')} />
</Group> </Group>
} }
@@ -266,7 +266,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled', 'Disable CSRF Protection')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled.label', 'Disable CSRF Protection')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')} {t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
</Text> </Text>
@@ -297,11 +297,11 @@ export default function AdminSecuritySection() {
{/* JWT Settings */} {/* JWT Settings */}
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt', 'JWT Configuration')}</Text> <Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt.label', 'JWT Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence', 'Enable Key Persistence')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence.label', 'Enable Key Persistence')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')} {t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
</Text> </Text>
@@ -317,7 +317,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation', 'Enable Key Rotation')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation.label', 'Enable Key Rotation')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')} {t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
</Text> </Text>
@@ -333,7 +333,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup', 'Enable Key Cleanup')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup.label', 'Enable Key Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')} {t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
</Text> </Text>
@@ -351,7 +351,7 @@ export default function AdminSecuritySection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}</span> <span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} /> <PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
</Group> </Group>
} }
@@ -365,7 +365,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie', 'Secure Cookie')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie.label', 'Secure Cookie')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')} {t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
</Text> </Text>
@@ -385,13 +385,13 @@ export default function AdminSecuritySection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.security.audit', 'Audit Logging')}</Text> <Text fw={600} size="sm">{t('admin.settings.security.audit.label', 'Audit Logging')}</Text>
<Badge color="grape" size="sm">ENTERPRISE</Badge> <Badge color="grape" size="sm">ENTERPRISE</Badge>
</Group> </Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.enabled', 'Enable Audit Logging')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.audit.enabled.label', 'Enable Audit Logging')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')} {t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')}
</Text> </Text>
@@ -409,7 +409,7 @@ export default function AdminSecuritySection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.audit.level', 'Audit Level')}</span> <span>{t('admin.settings.security.audit.level.label', 'Audit Level')}</span>
<PendingBadge show={isFieldPending('audit.level')} /> <PendingBadge show={isFieldPending('audit.level')} />
</Group> </Group>
} }
@@ -425,7 +425,7 @@ export default function AdminSecuritySection() {
<NumberInput <NumberInput
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}</span> <span>{t('admin.settings.security.audit.retentionDays.label', 'Audit Retention (days)')}</span>
<PendingBadge show={isFieldPending('audit.retentionDays')} /> <PendingBadge show={isFieldPending('audit.retentionDays')} />
</Group> </Group>
} }
@@ -443,7 +443,7 @@ export default function AdminSecuritySection() {
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Stack gap="md"> <Stack gap="md">
<div> <div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity', 'HTML URL Security')}</Text> <Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity.label', 'HTML URL Security')}</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t('admin.settings.security.htmlUrlSecurity.description', 'Configure URL access restrictions for HTML processing to prevent SSRF attacks')} {t('admin.settings.security.htmlUrlSecurity.description', 'Configure URL access restrictions for HTML processing to prevent SSRF attacks')}
</Text> </Text>
@@ -451,7 +451,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled', 'Enable URL Security')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled.label', 'Enable URL Security')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')} {t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
</Text> </Text>
@@ -475,7 +475,7 @@ export default function AdminSecuritySection() {
<Select <Select
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}</span> <span>{t('admin.settings.security.htmlUrlSecurity.level.label', 'Security Level')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.level')} /> <PendingBadge show={isFieldPending('html.urlSecurity.level')} />
</Group> </Group>
} }
@@ -507,7 +507,7 @@ export default function AdminSecuritySection() {
<Textarea <Textarea
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}</span> <span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains.label', 'Allowed Domains (Whitelist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} /> <PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} />
</Group> </Group>
} }
@@ -534,7 +534,7 @@ export default function AdminSecuritySection() {
<Textarea <Textarea
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}</span> <span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains.label', 'Blocked Domains (Blacklist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} /> <PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} />
</Group> </Group>
} }
@@ -561,7 +561,7 @@ export default function AdminSecuritySection() {
<Textarea <Textarea
label={ label={
<Group gap="xs"> <Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}</span> <span>{t('admin.settings.security.htmlUrlSecurity.internalTlds.label', 'Internal TLDs')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} /> <PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} />
</Group> </Group>
} }
@@ -588,7 +588,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks', 'Block Private Networks')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label', 'Block Private Networks')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')} {t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
</Text> </Text>
@@ -610,7 +610,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost', 'Block Localhost')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.label', 'Block Localhost')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')} {t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
</Text> </Text>
@@ -632,7 +632,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal', 'Block Link-Local Addresses')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.label', 'Block Link-Local Addresses')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')} {t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
</Text> </Text>
@@ -654,7 +654,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata', 'Block Cloud Metadata Endpoints')}</Text> <Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label', 'Block Cloud Metadata Endpoints')}</Text>
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')} {t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
</Text> </Text>
@@ -20,7 +20,7 @@ const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: Fl
disabled={disabled} disabled={disabled}
label={ label={
<div> <div>
<Text size="sm">{t('flatten.options.flattenOnlyForms', 'Flatten only forms')}</Text> <Text size="sm">{t('flatten.options.flattenOnlyForms.label', 'Flatten only forms')}</Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')} {t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')}
</Text> </Text>
@@ -93,8 +93,8 @@ describe('MergeSettings', () => {
); );
// Verify that translation keys are being called // Verify that translation keys are being called
expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?'); expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature.label', 'Remove digital signature in the merged file?');
expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?'); expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents.label', 'Generate table of contents in the merged file?');
}); });
}); });
@@ -19,14 +19,14 @@ const MergeSettings: React.FC<MergeSettingsProps> = ({
return ( return (
<Stack gap="md"> <Stack gap="md">
<Checkbox <Checkbox
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')} label={t('merge.removeDigitalSignature.label', 'Remove digital signature in the merged file?')}
checked={parameters.removeDigitalSignature} checked={parameters.removeDigitalSignature}
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)} onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
disabled={disabled} disabled={disabled}
/> />
<Checkbox <Checkbox
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')} label={t('merge.generateTableOfContents.label', 'Generate table of contents in the merged file?')}
checked={parameters.generateTableOfContents} checked={parameters.generateTableOfContents}
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)} onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
disabled={disabled} disabled={disabled}
@@ -161,8 +161,8 @@ describe('SanitizeSettings', () => {
// Verify that translation keys are being called (just check that it was called, not specific order) // Verify that translation keys are being called (just check that it was called, not specific order)
expect(mockT).toHaveBeenCalledWith('sanitize.options.title', expect.any(String)); expect(mockT).toHaveBeenCalledWith('sanitize.options.title', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript', expect.any(String)); expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript.label', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles', expect.any(String)); expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles.label', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.note', expect.any(String)); expect(mockT).toHaveBeenCalledWith('sanitize.options.note', expect.any(String));
}); });
@@ -12,8 +12,8 @@ const SanitizeSettings = ({ parameters, onParameterChange, disabled = false }: S
const { t } = useTranslation(); const { t } = useTranslation();
const options = (Object.keys(defaultParameters) as Array<keyof SanitizeParameters>).map((key) => ({ const options = (Object.keys(defaultParameters) as Array<keyof SanitizeParameters>).map((key) => ({
key: key, key,
label: t(`sanitize.options.${key}`, key), label: t(`sanitize.options.${key}.label`, key),
description: t(`sanitize.options.${key}.desc`, `${key} from the PDF`), description: t(`sanitize.options.${key}.desc`, `${key} from the PDF`),
default: defaultParameters[key], default: defaultParameters[key],
})); }));
@@ -0,0 +1,51 @@
import { describe, test, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
const LOCALES_DIR = path.join(__dirname, '../../../public/locales');
const getLocaleDirectories = () => {
if (!fs.existsSync(LOCALES_DIR)) {
return [];
}
return fs.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
};
const findDottedKeys = (node: unknown, segments: string[] = []): string[] => {
if (!node || typeof node !== 'object') {
return [];
}
const issues: string[] = [];
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (key.includes('.')) {
issues.push([...segments, key].join('.'));
}
issues.push(...findDottedKeys(value, [...segments, key]));
}
return issues;
};
const localeDirectories = getLocaleDirectories();
describe('Translation key structure', () => {
test('should locate locales directory', () => {
expect(fs.existsSync(LOCALES_DIR)).toBe(true);
});
test('should have at least one locale directory', () => {
expect(localeDirectories.length).toBeGreaterThan(0);
});
test.each(localeDirectories)('should not contain dotted keys in %s/translation.json', (localeDir) => {
const translationFile = path.join(LOCALES_DIR, localeDir, 'translation.json');
expect(fs.existsSync(translationFile)).toBe(true);
const data = JSON.parse(fs.readFileSync(translationFile, 'utf8'));
const dottedKeys = findDottedKeys(data);
expect(dottedKeys, `Dotted keys found in ${localeDir}: ${dottedKeys.join(', ')}`).toHaveLength(0);
});
});
@@ -668,7 +668,7 @@ export default function PeopleSection() {
<Stack gap="md" align="center"> <Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} /> <LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center"> <Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers', 'Invite Members')} {t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text> </Text>
{inviteMode === 'email' && ( {inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md"> <Text size="sm" c="dimmed" ta="center" px="md">