Compare commits

...
Author SHA1 Message Date
Dario Ghunney Ware 6dd6b12c14 changed swagger link 2025-12-10 11:30:36 +00:00
Dario Ghunney Ware ad84ebc851 set Swagger server to relative path 2025-12-10 11:12:36 +00:00
Dario Ghunney Ware cc3d8fb1ae changed devApiLink, removed redundant check 2025-12-10 11:12:36 +00:00
Dario Ghunney Ware a0a8f6e6b6 cleaning up 2025-12-10 11:12:36 +00:00
Dario Ghunney Ware 10ac098775 added new check for swagger endpoint 2025-12-10 11:12:36 +00:00
Anthony StirlingandGitHub f17ad56def Handle restricted language configuration fallback V2 (#5154)
## Summary
- restrict supported languages to the validated list from app-config
instead of always adding an extra fallback
- set the effective fallback locale to the preferred configured language
and switch away from disallowed selections automatically

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6930529bc6c08328a1ce05f7d1316e27)
2025-12-10 11:12:18 +00:00
Reece BrowneandGitHub de438d00e1 Fiix colours (#5211)
<img width="638" height="471" alt="image"
src="https://github.com/user-attachments/assets/c2352e25-6ee0-4726-9ce4-059870347ea6"
/>
<img width="638" height="450" alt="image"
src="https://github.com/user-attachments/assets/a17931ca-ffea-4dc3-a17a-629d98003da6"
/>
<img width="985" height="774" alt="image"
src="https://github.com/user-attachments/assets/b16203c6-a136-4fee-ba57-495f7b6e8c75"
/>
<img width="635" height="348" alt="image"
src="https://github.com/user-attachments/assets/4b4ab328-b2e4-442f-84a5-d947e97653ec"
/>
2025-12-10 11:10:34 +00:00
Anthony StirlingandGitHub 6787169583 Handle composition input in PDF text editor (#5192)
## Summary
- track IME composition state in the PDF text editor to avoid
interrupting phonetic input methods
- update text syncing to occur after composition completes and skip
redundant updates mid-composition

## Testing
- npm run lint -- --max-warnings 0


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693744be74148328bd3bda9150de6e56)
2025-12-10 10:11:45 +00:00
Anthony StirlingandGitHub 291e1a392b extra font support in text editor (#5208)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-10 10:11:16 +00:00
Anthony StirlingandGitHub 7c5aa3685f Add configurable SMTP TLS/SSL options (#5204)
## Summary
- add optional STARTTLS and SSL-related fields to mail settings with
defaults matching prior behavior
- apply the new mail properties in the SMTP JavaMail configuration,
including trust and hostname verification overrides
- update mail settings template and tests to cover default behavior and
explicit TLS/SSL overrides
- clarify STARTTLS naming and sslTrust usage with examples in property
comments and the settings template
- default sslTrust to a wildcard when unset so TLS connections accept
any host by default unless tightened

## Testing
- ./gradlew :proprietary:test --tests
stirling.software.proprietary.security.service.MailConfigTest --console
plain

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693864b2a6648328ae75c7e88a726a65)
2025-12-10 10:10:54 +00:00
Anthony StirlingGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
9c03914edd Add admin password reset option for users (#5180)
## Summary
- add backend support for admins to reset user passwords and optionally
email notifications when SMTP is enabled
- surface mail capability in admin settings data for the UI
- add a shared change-password modal hooked into People and Team user
actions with random password generation and email options

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b978fe3c83289b5b95dec79b3d38)

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-10 10:10:40 +00:00
James BruntonandGitHub c980ee10c0 Backport fixes from SaaS (#5187)
# Description of Changes
- Add new skeleton loader style type (block - nothing's currently using
it but it might as well be available)
- Make Dev API overridable (and set to the new docs that actually work
while Swagger docs don't work properly)
2025-12-09 11:45:01 +00:00
James BruntonandGitHub fa4d2bc09a Fix path to sample file in tour (#5186)
# Description of Changes
Fix path to sample file in tour
2025-12-09 11:43:18 +00:00
Anthony StirlingandGitHub 7faf7e50fa Chang etext on intro (#5160)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-06 00:06:11 +00:00
EthanHealy01andGitHub bb201ef9c1 Chore/bump gradle version number (#5176)
bump version number
2025-12-05 23:22:32 +00:00
82dbcfbb9b SSO login fix (#5167)
Fixes bug where SSO login with custom providers caused an
`InvalidClientRegistrationIdException: Invalid Client Registration with
Id: oidc` errors.

  Root Cause:
- Backend: Redirect URI was hardcoded to `/login/oauth2/code/oidc`
regardless of provider registration ID
- Frontend: Unknown providers were mapped back to 'oidc' instead of
using actual provider ID

Closes #5141

---------

Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Co-authored-by: Keon Chen <66115421+keonchennl@users.noreply.github.com>
2025-12-05 23:19:41 +00:00
EthanHealy01andGitHub 9fd8fd89ed add enum SERVER to list of valid licenses (#5172) 2025-12-05 13:18:23 +00:00
Keon ChenandGitHub 3a2370ea1f Update OCR setup guide link in LanguagePicker (#5162)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
Update the link to the doc page for language picker in OCR config
- Why the change was made
Old link doesn't work anymore
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ x] I have performed a self-review of my own code
- [ x] My changes generate no new warnings

### Documentation

- [x ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-04 21:35:11 +00:00
e7db714091 More fixes for automate (#5168)
# Description of Changes
Fix file missed in #5127 to use `apiClient` instead of `axios` directly

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-12-04 17:53:08 +00:00
c6b4a2b141 Desktop to match normal login screens (#5122)1
Also fixed issue with csrf
Also fixed issue with rust keychain

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-04 17:48:19 +00:00
Anthony StirlingandGitHub 7459463a3c V2 sso in server plan (#5158)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-03 21:12:29 +00:00
c9bf436895 couple of small fixes for text editor (#5155)
# Description of Changes

- Workbench.tsx: Allow PDF text editor to handle it's own custom state
(this is a bit of a hotfix, we're going to handle the PDF text editors
file upload flow better in a future PR).
- PdfTextEditorView.tsx: Added a dropzone and some helper text to upload
the first file.
- PdfTextEditorView.tsx: Hide document view when isConverting is true.
Prevents showing stale content from previous file during conversion.
- useTranslatedToolRegistry.tsx: Moved PDF Text Editor to top of
Recommended tools list. Increased visibility of the new feature.
- PdfTextEditor.tsx: Removed auto-navigation to PDF Editor workbench on
file selection. Stops the "jumpy" behavior when selecting files.
- HomePage.tsx: Check specifically for pdfTextEditor tool instead of any
custom workbench. Prevents auto-switch to viewer when uploading files
while in PDF Text Editor.

<img width="2056" height="1073" alt="Screenshot 2025-12-03 at 6 01
14 PM"
src="https://github.com/user-attachments/assets/dfc63a46-7991-486c-ba00-0ce7637502f5"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-12-03 20:37:23 +00:00
EthanHealy01andGitHub f8dbf171e1 Feature/v2/get all info on pdf (#5105)
# Description of Changes

- Addition of the get all info on PDF tool

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-03 20:02:42 +00:00
ConnorYohandGitHub e59c717dc0 Fixes state management loops around getting results V2 (#5153)
Makes sure settings step collapses in results step

Makes sure result step doesn't always reset even in development for
baseTool

Make sure result step doesn't reset for convert
2025-12-03 17:42:04 +00:00
ConnorYohandGitHub f2bffe2dc6 Fix-convert-V2 (#5147)
Custom processors can now return consume all inputs flag. This allows to
have many inputs to single output consumption

Fixed multi call conversion logic
2025-12-03 17:39:49 +00:00
162 changed files with 5201 additions and 1127 deletions
@@ -112,7 +112,6 @@ public class ApplicationProperties {
@Data
public static class Security {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
@@ -575,6 +574,16 @@ public class ApplicationProperties {
private String username;
@ToString.Exclude private String password;
private String from;
// STARTTLS upgrades a plain SMTP connection to TLS after connecting (RFC 3207)
private Boolean startTlsEnable = true;
private Boolean startTlsRequired;
// SSL/TLS wrapper for implicit TLS (typically port 465)
private Boolean sslEnable;
// Hostnames or patterns (e.g., "smtp.example.com" or "*") to trust for TLS certificates;
// defaults to "*" (trust all) when not set
private String sslTrust;
// Enables hostname verification for TLS connections
private Boolean sslCheckServerIdentity;
}
@Data
@@ -254,10 +254,7 @@ public class PostHogService {
properties,
"security_enableLogin",
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty(properties, "security_csrfDisabled", true);
addIfNotEmpty(
properties,
"security_loginAttemptCount",
@@ -162,10 +162,9 @@ public class RequestUriUtils {
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs");
|| trimmedUri.startsWith("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
@@ -34,7 +34,6 @@ public class InitialSetup {
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
GeneralUtils.extractPipeline();
@@ -60,18 +59,6 @@ public class InitialSetup {
}
}
public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher(
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
if (!csrf) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
applicationProperties.getSecurity().setCsrfDisabled(false);
}
}
}
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
@@ -95,7 +82,7 @@ public class InitialSetup {
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
|| "0.0.0".equals(existingVersion);
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
@@ -62,10 +62,14 @@ public class OpenApiConfig {
// Add server configuration from environment variable
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
Server server;
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
Server server = new Server().url(swaggerServerUrl).description("API Server");
openAPI.addServersItem(server);
server = new Server().url(swaggerServerUrl).description("API Server");
} else {
// Use relative path so Swagger uses the current browser origin to avoid CORS issues when accessing via different ports
server = new Server().url("/").description("Current Server");
}
openAPI.addServersItem(server);
// Add ErrorResponse schema to components
Schema<?> errorResponseSchema =
@@ -124,7 +124,6 @@ public class SettingsController {
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
@@ -159,12 +158,6 @@ public class SettingsController {
.getSecurity()
.setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("csrfDisabled")) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
applicationProperties
.getSecurity()
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties
@@ -74,6 +74,7 @@ public class ConfigController {
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
@@ -4,8 +4,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
@@ -13,6 +11,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
@Controller
@@ -63,9 +62,10 @@ public class ReactRoutingController {
}
}
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
throws IOException {
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
@@ -75,8 +75,7 @@ public class ReactRoutingController {
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request)
throws IOException {
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@@ -33,8 +33,12 @@ public class PdfJsonFallbackFontService {
public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk";
public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp";
public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean";
public static final String FALLBACK_FONT_TC_ID = "fallback-noto-tc";
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
public static final String FALLBACK_FONT_DEVANAGARI_ID = "fallback-noto-devanagari";
public static final String FALLBACK_FONT_MALAYALAM_ID = "fallback-noto-malayalam";
public static final String FALLBACK_FONT_TIBETAN_ID = "fallback-noto-tibetan";
// Font name aliases map PDF font names to available fallback fonts
// This provides better visual consistency when editing PDFs
@@ -59,6 +63,22 @@ public class PdfJsonFallbackFontService {
Map.entry("dejavuserif", "fallback-dejavu-serif"),
Map.entry("dejavumono", "fallback-dejavu-mono"),
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
// Traditional Chinese fonts (Taiwan, Hong Kong, Macau)
Map.entry("mingliu", "fallback-noto-tc"),
Map.entry("pmingliu", "fallback-noto-tc"),
Map.entry("microsoftjhenghei", "fallback-noto-tc"),
Map.entry("jhenghei", "fallback-noto-tc"),
Map.entry("kaiti", "fallback-noto-tc"),
Map.entry("kaiu", "fallback-noto-tc"),
Map.entry("dfkaib5", "fallback-noto-tc"),
Map.entry("dfkai", "fallback-noto-tc"),
// Simplified Chinese fonts (Mainland China) - more common
Map.entry("simsun", "fallback-noto-cjk"),
Map.entry("simhei", "fallback-noto-cjk"),
Map.entry("microsoftyahei", "fallback-noto-cjk"),
Map.entry("yahei", "fallback-noto-cjk"),
Map.entry("songti", "fallback-noto-cjk"),
Map.entry("heiti", "fallback-noto-cjk"),
// Noto Sans - Google's universal font (use as last resort generic fallback)
Map.entry("noto", "fallback-noto-sans"),
Map.entry("notosans", "fallback-noto-sans"));
@@ -83,6 +103,12 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
"NotoSansKR-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TC_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansTC-Regular.ttf",
"NotoSansTC-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_AR_ID,
new FallbackFontSpec(
@@ -95,6 +121,24 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
"NotoSansThai-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_DEVANAGARI_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansDevanagari-Regular.ttf",
"NotoSansDevanagari-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_MALAYALAM_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansMalayalam-Regular.ttf",
"NotoSansMalayalam-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TIBETAN_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSerifTibetan-Regular.ttf",
"NotoSerifTibetan-Regular",
"ttf")),
// Liberation Sans family
Map.entry(
"fallback-liberation-sans",
@@ -484,6 +528,20 @@ public class PdfJsonFallbackFontService {
*/
public String resolveFallbackFontId(int codePoint) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
// Bopomofo is primarily used in Taiwan for Traditional Chinese phonetic annotation
if (block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED) {
return FALLBACK_FONT_TC_ID;
}
// Compatibility ideographs are primarily used by Traditional Chinese encodings (e.g., Big5,
// HKSCS) so prefer the Traditional Chinese fallback here.
if (block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT) {
return FALLBACK_FONT_TC_ID;
}
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
@@ -492,19 +550,23 @@ public class PdfJsonFallbackFontService {
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
return FALLBACK_FONT_CJK_ID;
}
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.)
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
case ARABIC -> FALLBACK_FONT_AR_ID;
case THAI -> FALLBACK_FONT_TH_ID;
case DEVANAGARI -> FALLBACK_FONT_DEVANAGARI_ID;
case MALAYALAM -> FALLBACK_FONT_MALAYALAM_ID;
case TIBETAN -> FALLBACK_FONT_TIBETAN_ID;
default -> FALLBACK_FONT_ID;
};
}
@@ -12,7 +12,6 @@
security:
enableLogin: true # set to 'true' to enable login
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
@@ -105,6 +104,11 @@ mail:
username: '' # SMTP server username
password: '' # SMTP server password
from: '' # sender email address
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
@@ -205,6 +205,10 @@ public class ProprietaryUIDataController {
data.setLoginMethod(securityProps.getLoginMethod());
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
// Add language configuration for login page
data.setLanguages(applicationProperties.getUi().getLanguages());
data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale());
return ResponseEntity.ok(data);
}
@@ -328,6 +332,7 @@ public class ProprietaryUIDataController {
data.setGrandfatheredUserCount(grandfatheredCount);
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
return ResponseEntity.ok(data);
}
@@ -376,7 +381,7 @@ public class ProprietaryUIDataController {
data.setUsername(username);
data.setRole(user.get().getRolesAsString());
data.setSettings(settingsJson);
data.setChangeCredsFlag(user.get().isFirstLogin());
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
@@ -491,6 +496,8 @@ public class ProprietaryUIDataController {
private boolean altLogin;
private boolean firstTimeSetup;
private boolean showDefaultCredentials;
private List<String> languages;
private String defaultLocale;
}
@Data
@@ -510,6 +517,7 @@ public class ProprietaryUIDataController {
private int grandfatheredUserCount;
private int licenseMaxUsers;
private boolean premiumEnabled;
private boolean mailEnabled;
}
@Data
@@ -33,7 +33,8 @@ public class MailConfig {
// Creates a new instance of JavaMailSenderImpl, which is a Spring implementation
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
String host = mailProperties.getHost();
mailSender.setHost(host);
mailSender.setPort(mailProperties.getPort());
mailSender.setDefaultEncoding("UTF-8");
@@ -70,8 +71,32 @@ public class MailConfig {
log.info("SMTP authentication disabled - no credentials provided");
}
boolean startTlsEnabled =
mailProperties.getStartTlsEnable() == null || mailProperties.getStartTlsEnable();
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.enable", Boolean.toString(startTlsEnabled));
if (mailProperties.getStartTlsRequired() != null) {
props.put(
"mail.smtp.starttls.required", mailProperties.getStartTlsRequired().toString());
}
if (mailProperties.getSslEnable() != null) {
props.put("mail.smtp.ssl.enable", mailProperties.getSslEnable().toString());
}
// Trust the configured host to allow STARTTLS with self-signed certificates
String sslTrust = mailProperties.getSslTrust();
if (sslTrust == null || sslTrust.trim().isEmpty()) {
sslTrust = "*";
}
if (sslTrust != null && !sslTrust.trim().isEmpty()) {
props.put("mail.smtp.ssl.trust", sslTrust);
}
if (mailProperties.getSslCheckServerIdentity() != null) {
props.put(
"mail.smtp.ssl.checkserveridentity",
mailProperties.getSslCheckServerIdentity().toString());
}
// Returns the configured mail sender, ready to send emails
return mailSender;
@@ -1,7 +1,6 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
@@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
@@ -198,9 +194,7 @@ public class SecurityConfiguration {
http.cors(cors -> cors.disable());
}
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
@@ -210,48 +204,6 @@ public class SecurityConfiguration {
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
@@ -331,7 +283,9 @@ public class SecurityConfiguration {
formLogin ->
formLogin
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl("/perform_login") // Process form posts here (not /login)
.loginProcessingUrl(
"/perform_login") // Process form posts here (not
// /login)
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
@@ -7,6 +7,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import jakarta.mail.MessagingException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
@@ -236,6 +238,8 @@ public class UserController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
// Set flags before changing password so they're saved together
user.setForcePasswordChange(false);
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
@@ -584,6 +588,79 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changePasswordForUser")
public ResponseEntity<?> changePasswordForUser(
@RequestParam(name = "username") String username,
@RequestParam(name = "newPassword", required = false) String newPassword,
@RequestParam(name = "generateRandom", defaultValue = "false") boolean generateRandom,
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
@RequestParam(name = "includePassword", defaultValue = "false") boolean includePassword,
@RequestParam(name = "forcePasswordChange", defaultValue = "false")
boolean forcePasswordChange,
HttpServletRequest request,
Authentication authentication)
throws SQLException, UnsupportedProviderException, MessagingException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
String currentUsername = authentication.getName();
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot change your own password."));
}
User user = userOpt.get();
String finalPassword = newPassword;
if (generateRandom) {
finalPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
if (finalPassword == null || finalPassword.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "New password is required."));
}
// Set force password change flag before changing password so both are saved together
user.setForcePasswordChange(forcePasswordChange);
userService.changePassword(user, finalPassword);
// Invalidate all active sessions to force reauthentication
userService.invalidateUserSessions(username);
if (sendEmail) {
if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email is not configured."));
}
String userEmail = user.getUsername();
// Check if username is a valid email format
if (userEmail == null || userEmail.isBlank() || !userEmail.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"User's email is not a valid email address. Notifications are disabled."));
}
String loginUrl = buildLoginUrl(request);
emailService
.get()
.sendPasswordChangedNotification(
userEmail,
user.getUsername(),
includePassword ? finalPassword : null,
loginUrl);
}
return ResponseEntity.ok(Map.of("message", "User password updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public ResponseEntity<?> changeUserEnabled(
@@ -26,6 +26,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtServiceInterface jwtService;
@@ -47,19 +49,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
public JwtAuthenticationFilter(
JwtServiceInterface jwtService,
UserService userService,
CustomUserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
ApplicationProperties.Security securityProperties) {
this.jwtService = jwtService;
this.userService = userService;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.securityProperties = securityProperties;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -68,7 +57,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (isStaticResource(contextPath, requestURI)) {
filterChain.doFilter(request, response);
return;
}
@@ -77,10 +70,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow specific auth endpoints to pass through without JWT
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Allow auth endpoints to pass through without JWT
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
@@ -241,24 +241,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -59,6 +59,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "forcePasswordChange")
private Boolean forcePasswordChange = false;
@Column(name = "roleName")
private String roleName;
@@ -117,6 +120,14 @@ public class User implements UserDetails, Serializable {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public boolean isForcePasswordChange() {
return forcePasswordChange != null && forcePasswordChange;
}
public void setForcePasswordChange(boolean forcePasswordChange) {
this.forcePasswordChange = forcePasswordChange;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
log.warn(
"OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
log.warn(
"OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)",
username);
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
@@ -67,10 +67,15 @@ public class OAuth2Configuration {
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
log.error("No OAuth2 provider registered");
log.error("No OAuth2 provider registered - check your OAuth2 configuration");
throw new NoProviderFoundException("At least one OAuth2 provider must be configured.");
}
log.info(
"OAuth2 ClientRegistrationRepository created with {} provider(s): {}",
registrations.size(),
registrations.stream().map(ClientRegistration::getRegistrationId).toList());
return new InMemoryClientRegistrationRepository(registrations);
}
@@ -165,7 +170,6 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(
@@ -208,7 +212,19 @@ public class OAuth2Configuration {
null,
null);
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
boolean isValid =
!isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider);
if (isValid) {
log.info(
"Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'",
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
? Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId(name)
@@ -217,7 +233,7 @@ public class OAuth2Configuration {
.scope(oidcProvider.getScopes())
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
.clientName(clientName)
.redirectUri(REDIRECT_URI_PATH + "oidc")
.redirectUri(REDIRECT_URI_PATH + name)
.authorizationGrantType(AUTHORIZATION_CODE)
.build())
: Optional.empty();
@@ -67,19 +67,25 @@ public class CustomSaml2AuthenticationSuccessHandler
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has paid license)
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block SAML login
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
} else if (!licenseSettingsService.isSamlEligible(null)) {
// No existing user and no ENTERPRISE license -> block auto creation
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
@@ -223,4 +223,53 @@ public class EmailService {
sendPlainEmail(to, subject, body, true);
}
@Async
public void sendPasswordChangedNotification(
String to, String username, String newPassword, String loginUrl) throws MessagingException {
String subject = "Your Stirling PDF password has been updated";
String passwordSection =
newPassword == null
? ""
: """
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
</div>
"""
.formatted(newPassword);
String body =
"""
<html><body style=\"margin: 0; padding: 0;\">
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
<div style=\"text-align: center; padding: 20px; background-color: #222;\">
<img src=\"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg\" alt=\"Stirling PDF\" style=\"max-height: 60px;\">
</div>
<div style=\"padding: 30px; color: #333;\">
<h2 style=\"color: #222; margin-top: 0;\">Your password was changed</h2>
<p>Hello %s,</p>
<p>An administrator has updated the password for your Stirling PDF account.</p>
%s
<p>If you did not expect this change, please contact your administrator immediately.</p>
<div style=\"text-align: center; margin: 30px 0;\">
<a href=\"%s\" style=\"display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;\">Go to Stirling PDF</a>
</div>
<p style=\"font-size: 14px; color: #666;\">Or copy and paste this link in your browser:</p>
<div style=\"background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;\">
%s
</div>
</div>
<div style=\"text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;\">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, passwordSection, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true);
}
}
@@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -343,17 +344,76 @@ public class UserLicenseSettingsService {
* @param user The user to check
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
public boolean isOAuthEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username);
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true;
}
// Users can use OAuth/SAML only if system has ENTERPRISE license
boolean hasEnterpriseLicense = hasEnterpriseLicense();
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
return hasEnterpriseLicense;
// todo: remove
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
}
// Users can use OAuth with SERVER or ENTERPRISE license
boolean hasPaid = hasPaidLicense();
log.info(
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
hasPaid,
username,
hasPaid);
return hasPaid;
}
/**
* Checks if a user is eligible to use SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SAML is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use SAML
*/
public boolean isSamlEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username);
// Grandfathered users always have SAML access
if (user != null && user.isOauthGrandfathered()) {
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
return true;
}
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting SAML2 login - checking license requirement");
}
// Users can use SAML only with ENTERPRISE license
boolean hasEnterprise = hasEnterpriseLicense();
log.info(
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
hasEnterprise,
username,
hasEnterprise);
return hasEnterprise;
}
/**
@@ -495,8 +555,12 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE);
log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid);
return hasPaid;
}
/**
@@ -510,7 +574,19 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
log.info(
"License check result: type={}, requiresEnterprise=true, hasEnterprise={}",
license,
(license == License.ENTERPRISE));
if (license != License.ENTERPRISE) {
log.warn(
"SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.",
license);
}
return license == License.ENTERPRISE;
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
/**
* Unit tests for OAuth2Configuration redirect URI logic.
*
* <p>These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment
* MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing
* InvalidClientRegistrationIdException when custom provider names were used.
*
* <p>Note: These are conceptual tests documenting the expected behavior. Full integration testing
* with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2.
* Valid OIDC configuration responses 3. Network mocking infrastructure
*/
class OAuth2ConfigurationTest {
/**
* Tests the redirect URI pattern for OIDC provider configurations.
*
* <p>Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the
* registration ID. For example: - Provider name: "authentik" → Redirect URI:
* "/login/oauth2/code/authentik" - Provider name: "mycompany" → Redirect URI:
* "/login/oauth2/code/mycompany" - Provider name: "oidc" → Redirect URI:
* "/login/oauth2/code/oidc"
*
* <p>Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for
* a registration with ID 'oidc' when the provider redirected back. This caused
* InvalidClientRegistrationIdException when custom provider names were used.
*/
@Test
void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() {
// Verify the redirect URI pattern constant
String redirectUriBase = "{baseUrl}/login/oauth2/code/";
// Test cases: provider name → expected redirect URI
String[][] testCases = {
{"authentik", redirectUriBase + "authentik"},
{"mycompany", redirectUriBase + "mycompany"},
{"oidc", redirectUriBase + "oidc"},
{"okta", redirectUriBase + "okta"},
{"auth0", redirectUriBase + "auth0"}
};
for (String[] testCase : testCases) {
String providerName = testCase[0];
String expectedRedirectUri = testCase[1];
// The fix ensures: .redirectUri(REDIRECT_URI_PATH + name)
// instead of: .redirectUri(REDIRECT_URI_PATH + "oidc")
String actualRedirectUri = redirectUriBase + providerName;
assertEquals(
expectedRedirectUri,
actualRedirectUri,
String.format(
"Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'",
providerName));
}
}
/**
* Documents the critical fix for OAuth2 redirect URI mismatch.
*
* <p>This test validates the logic that was changed in OAuth2Configuration.java line 220:
*
* <pre>
* // BEFORE (bug):
* .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc"
*
* // AFTER (fix):
* .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name
* </pre>
*/
@Test
void testCriticalFix_redirectUriMatchesRegistrationId() {
// The redirect URI path segment extraction by Spring Security
String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123";
// Spring extracts the path segment between "code/" and "?"
String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl);
// The extracted ID MUST match an actual registration ID
assertEquals("authentik", extractedRegistrationId);
// If we had used hardcoded "oidc", the callback would be:
String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123";
String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl);
// This would look for registration with ID "oidc" but we registered "authentik"
assertEquals("oidc", buggyExtractedId);
// The mismatch: registrationId="authentik", but Spring looks for "oidc"
// Result: InvalidClientRegistrationIdException
assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed");
}
/** Helper method simulating Spring's extraction of registration ID from callback URL */
private String extractRegistrationIdFromCallback(String callbackUrl) {
// Simplified version of what Spring Security does
// Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path
String path = callbackUrl.split("\\?")[0];
String[] parts = path.split("/");
return parts[parts.length - 1]; // Last path segment
}
/**
* Validates the frontend-backend flow for custom provider names.
*
* <p>Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend:
* ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to
* "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with
* "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for
* OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring
* Security redirects to provider with redirect_uri containing "authentik" 8. Provider:
* Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security
* extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID
* "authentik" ✅ SUCCESS
*
* <p>If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
* "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
* Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
* InvalidClientRegistrationIdException
*/
@Test
void testEndToEndFlow_registrationIdConsistency() {
String providerName = "authentik";
// Step 2: Registration ID
String registrationId = providerName;
assertEquals("authentik", registrationId);
// Step 3: Redirect URI (MUST use same name)
String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
// Step 4: Provider list endpoint
String authorizationPath = "/oauth2/authorization/" + providerName;
assertEquals("/oauth2/authorization/authentik", authorizationPath);
// Step 5: Frontend extracts provider ID
String frontendProviderId =
authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
assertEquals("authentik", frontendProviderId);
// Step 6-8: OAuth flow (external)
// Step 9: Callback URL from provider
String callbackUrl =
"http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
String extractedId = extractRegistrationIdFromCallback(callbackUrl);
// Step 10: Registration lookup
assertEquals(
registrationId,
extractedId,
"Registration ID from callback MUST match original registration ID");
}
}
@@ -27,6 +27,11 @@ class MailConfigTest {
when(mailProps.getPort()).thenReturn(587);
when(mailProps.getUsername()).thenReturn("user@example.com");
when(mailProps.getPassword()).thenReturn("password");
when(mailProps.getStartTlsEnable()).thenReturn(null);
when(mailProps.getStartTlsRequired()).thenReturn(null);
when(mailProps.getSslEnable()).thenReturn(null);
when(mailProps.getSslTrust()).thenReturn(null);
when(mailProps.getSslCheckServerIdentity()).thenReturn(null);
}
@Test
@@ -50,6 +55,32 @@ class MailConfigTest {
() -> assertEquals("password", impl.getPassword()),
() -> assertEquals("UTF-8", impl.getDefaultEncoding()),
() -> assertEquals("true", props.getProperty("mail.smtp.auth")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")));
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")),
() -> assertEquals(null, props.getProperty("mail.smtp.starttls.required")),
() -> assertEquals(null, props.getProperty("mail.smtp.ssl.enable")),
() -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")));
}
@Test
void shouldRespectExplicitTlsOverrides() {
ApplicationProperties appProps = mock(ApplicationProperties.class);
when(mailProps.getStartTlsEnable()).thenReturn(false);
when(mailProps.getStartTlsRequired()).thenReturn(true);
when(mailProps.getSslEnable()).thenReturn(true);
when(mailProps.getSslTrust()).thenReturn("*");
when(mailProps.getSslCheckServerIdentity()).thenReturn(true);
when(appProps.getMail()).thenReturn(mailProps);
MailConfig config = new MailConfig(appProps);
JavaMailSenderImpl impl = (JavaMailSenderImpl) config.javaMailSender();
Properties props = impl.getJavaMailProperties();
assertAll(
() -> assertEquals("false", props.getProperty("mail.smtp.starttls.enable")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.required")),
() -> assertEquals("true", props.getProperty("mail.smtp.ssl.enable")),
() -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")),
() -> assertEquals("true", props.getProperty("mail.smtp.ssl.checkserveridentity")));
}
}
@@ -267,4 +267,222 @@ class UserLicenseSettingsServiceTest {
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
// ===== OAuth Eligibility Tests =====
@Test
void isOAuthEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
// Non-grandfathered user with SERVER license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isOAuthEligible(user);
assertEquals(
true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without paid license should NOT be eligible");
}
@Test
void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
// New user (null) with SERVER license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(null);
assertEquals(
true, result, "New user with SERVER license should be eligible for auto-creation");
}
@Test
void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
// New user (null) without license should NOT be eligible
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(null);
assertEquals(
false,
result,
"New user without paid license should NOT be eligible for auto-creation");
}
@Test
void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, OAuth should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isOAuthEligible(user);
assertEquals(
false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
}
// ===== SAML Eligibility Tests =====
@Test
void isSamlEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible for SAML regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(user);
assertEquals(
true,
result,
"Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
// Non-grandfathered user with SERVER license should NOT be eligible for SAML
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user with SERVER license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
// New user (null) with ENTERPRISE license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(null);
assertEquals(
true,
result,
"New user with ENTERPRISE license should be eligible for SAML auto-creation");
}
@Test
void isSamlEligible_newUserWithServerLicense_returnsFalse() {
// New user (null) with SERVER license should NOT be eligible for SAML
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(null);
assertEquals(
false,
result,
"New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
}
@Test
void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, SAML should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isSamlEligible(user);
assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.3'
version = '2.1.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+41 -16
View File
@@ -456,6 +456,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -499,6 +500,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -579,6 +581,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
@@ -678,6 +681,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -694,6 +698,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -711,6 +716,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -764,6 +770,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -798,6 +805,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -834,6 +842,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -909,6 +918,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@@ -1064,6 +1074,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1107,6 +1118,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2137,6 +2149,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2187,6 +2200,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2254,6 +2268,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3186,6 +3201,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3304,7 +3320,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4081,6 +4096,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4409,6 +4425,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4419,6 +4436,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4488,6 +4506,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5201,7 +5220,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5211,7 +5229,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5222,7 +5239,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5235,7 +5251,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5262,6 +5277,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5669,7 +5685,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5946,6 +5961,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6993,7 +7009,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7388,6 +7405,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7558,6 +7576,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7724,8 +7743,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7790,7 +7808,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8881,6 +8898,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9357,7 +9375,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9678,6 +9695,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10264,8 +10282,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -11411,6 +11428,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11690,6 +11708,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12072,6 +12091,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12081,6 +12101,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13592,7 +13613,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13801,6 +13821,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14102,6 +14123,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14183,6 +14205,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14387,6 +14410,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14538,6 +14562,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14551,6 +14576,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15162,8 +15188,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
@@ -3828,8 +3828,8 @@ title = "التحليلات"
description = "تساعدنا هذه الملفات على فهم كيفية استخدام أدواتنا، كي نركّز على بناء الميزات الأكثر قيمة لمجتمعنا. كن مطمئنًا—‏Stirling PDF لا يمكنه ولن يتتبع محتوى المستندات التي تعمل عليها."
[cookieBanner.services]
posthog = "PostHog Analytics"
scarf = "Scarf Pixel"
posthog = "تحليلات PostHog"
scarf = "Scarf بكسل"
[removeMetadata]
submit = "إزالة البيانات الوصفية"
@@ -5177,7 +5177,7 @@ upgrade = "الترقية الآن →"
freeTitle = "ترخيص الخادم"
overLimitTitle = "مطلوب ترخيص خادم"
overLimitBody = "ترخيصنا يسمح حتى <strong>{{freeTierLimit}}</strong> مستخدمين مجاناً لكل خادم. لديك <strong>{{overLimitUserCopy}}</strong> مستخدمي Stirling. للمتابعة دون انقطاع، ارقَ إلى خطة خادم Stirling - <strong>مقاعد غير محدودة</strong>، تحرير نصوص PDF، وتحكم إداري كامل مقابل $99/خادم/شهرياً."
freeBody = "ترخيص <strong>Open-Core</strong> لدينا يسمح حتى <strong>{{freeTierLimit}}</strong> مستخدمين مجاناً لكل خادم. للتوسع بسلاسة والحصول على وصول مبكر إلى <strong>أداة تحرير نصوص PDF</strong> الجديدة، نوصي بخطة خادم Stirling - تحرير كامل و<strong>مقاعد غير محدودة</strong> مقابل $99/خادم/شهرياً."
freeBody = "يتيح ترخيصنا <strong>Open-Core</strong> ما يصل إلى <strong>{{freeTierLimit}}</strong> مستخدمًا مجانًا لكل خادم. للتوسع دون انقطاع، نوصي بخطة Stirling Server - <strong>مقاعد غير محدودة</strong> و<strong>دعم SSO</strong> مقابل $99/server/mo."
[onboarding.desktopInstall]
title = "تنزيل"
@@ -5177,7 +5177,7 @@ upgrade = "İndi yüksəlt →"
freeTitle = "Server lisenziyası"
overLimitTitle = "Server lisenziyası tələb olunur"
overLimitBody = "Lisenziyalaşmamız hər server üçün pulsuz olaraq maksimum <strong>{{freeTierLimit}}</strong> istifadəçiyə icazə verir. Sizdə <strong>{{overLimitUserCopy}}</strong> Stirling istifadəçisi var. Fasiləsiz davam etmək üçün Stirling Server planına yüksəldin - <strong>limitsiz yerlər</strong>, PDF mətn redaktəsi və tam admin nəzarəti cəmi $99/server/ay."
freeBody = "Bizim <strong>Open-Core</strong> lisenziyası hər server üçün pulsuz olaraq maksimum <strong>{{freeTierLimit}}</strong> istifadəçiyə icazə verir. Fasiləsiz miqyaslanmaq və yeni <strong>PDF mətn redaktəsi alətimizə</strong> erkən çıxış əldə etmək üçün Stirling Server planını tövsiyə edirik — tam redaktə və <strong>limitsiz yerlər</strong> $99/server/ay."
freeBody = "Bizim <strong>Open-Core</strong> lisenziyalaşdırmamız hər server üçün pulsuz olaraq ən çox <strong>{{freeTierLimit}}</strong> istifadəçiyə icazə verir. Fasiləsiz miqyaslama üçün Stirling Server planını tövsiyə edirik - <strong>limitsiz yerlər</strong> və <strong>SSO dəstəyi</strong> $99/server/ay."
[onboarding.desktopInstall]
title = "Yüklə"
@@ -5177,7 +5177,7 @@ upgrade = "Надградете сега →"
freeTitle = "Лиценз за сървър"
overLimitTitle = "Необходим е лиценз за сървър"
overLimitBody = "Нашият лиценз позволява до <strong>{{freeTierLimit}}</strong> безплатни потребители на сървър. Имате <strong>{{overLimitUserCopy}}</strong> потребители на Stirling. За да продължите без прекъсвания, надградете до плана Stirling Server – <strong>неограничени места</strong>, редакция на PDF текст и пълен админ контрол за $99/сървър/месец."
freeBody = "Нашият <strong>Open-Core</strong> лиценз позволява до <strong>{{freeTierLimit}}</strong> безплатни потребители на сървър. За да мащабирате без прекъсвания и да получите ранен достъп до нашия нов <strong>инструмент за редакция на PDF текст</strong>, препоръчваме плана Stirling Server – пълно редактиране и <strong>неограничени места</strong> за $99/сървър/месец."
freeBody = "Нашият лицензен модел <strong>Open-Core</strong> позволява до <strong>{{freeTierLimit}}</strong> потребители безплатно на сървър. За безпрепятствено мащабиране препоръчваме плана Stirling Server - <strong>неограничени места</strong> и <strong>поддръжка на SSO</strong> за $99/server/mo."
[onboarding.desktopInstall]
title = "Изтегляне"
@@ -352,7 +352,7 @@ teams = "Equips"
title = "Configuració"
systemSettings = "Configuració del sistema"
features = "Funcions"
endpoints = "Endpoints"
endpoints = "Punts finals"
database = "Base de dades"
advanced = "Avançat"
@@ -561,7 +561,7 @@ totalEndpoints = "Total d'endpoints"
totalVisits = "Total de visites"
showing = "Mostrant"
selectedVisits = "Visites seleccionades"
endpoint = "Endpoint"
endpoint = "Punt final"
visits = "Visites"
percentage = "Percentatge"
loading = "Carregant..."
@@ -4366,7 +4366,7 @@ features = "Banderes de funcions"
processing = "Processament"
[admin.settings.advanced.endpoints]
label = "Endpoints"
label = "Punts finals"
manage = "Gestiona els endpoints de l'API"
description = "La gestió d'endpoints es configura via YAML. Consulteu la documentació per a detalls sobre com habilitar/deshabilitar endpoints específics."
@@ -5177,7 +5177,7 @@ upgrade = "Actualitza ara →"
freeTitle = "Llicència del servidor"
overLimitTitle = "Cal una llicència de servidor"
overLimitBody = "La nostra llicència permet fins a <strong>{{freeTierLimit}}</strong> usuaris gratuïts per servidor. Tens <strong>{{overLimitUserCopy}}</strong> usuaris de Stirling. Per continuar sense interrupcions, actualitza al pla Stirling Server: <strong>seients il·limitats</strong>, edició de text de PDF i control d'administració complet per 99 $/servidor/mes."
freeBody = "La nostra llicència <strong>Open-Core</strong> permet fins a <strong>{{freeTierLimit}}</strong> usuaris gratuïts per servidor. Per escalar sense interrupcions i obtenir accés anticipat a la nova <strong>eina d'edició de text PDF</strong>, recomanem el pla Stirling Server: edició completa i <strong>seients il·limitats</strong> per 99 $/servidor/mes."
freeBody = "La nostra llicència <strong>Open-Core</strong> permet fins a <strong>{{freeTierLimit}}</strong> usuaris gratuïts per servidor. Per escalar sense interrupcions, recomanem el pla Stirling Server - <strong>places il·limitades</strong> i <strong>suport SSO</strong> per $99/servidor/mes."
[onboarding.desktopInstall]
title = "Baixa"
@@ -5754,7 +5754,7 @@ title = "Gràfic d'ús dels endpoints"
[usage.table]
title = "Estadístiques detallades"
endpoint = "Endpoint"
endpoint = "Punt final"
visits = "Visites"
percentage = "Percentatge"
noData = "No hi ha dades disponibles"
@@ -4176,7 +4176,7 @@ description = "Sledovat akce uživatelů a systémové události pro compliance
[admin.settings.security.audit.level]
label = "Úroveň auditu"
description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE"
description = "0=VYPNUTO, 1=ZÁKLADNÍ, 2=STANDARDNÍ, 3=PODROBNÝ"
[admin.settings.security.audit.retentionDays]
label = "Doba uchování auditů (dny)"
@@ -5177,7 +5177,7 @@ upgrade = "Upgradovat nyní →"
freeTitle = "Serverová licence"
overLimitTitle = "Vyžadována serverová licence"
overLimitBody = "Naše licencování umožňuje až <strong>{{freeTierLimit}}</strong> uživatelů zdarma na server. Máte <strong>{{overLimitUserCopy}}</strong> uživatelů Stirling. Pro nepřerušené používání přejděte na plán Stirling Server – <strong>neomezený počet míst</strong>, úpravy textu PDF a plná správa za 99 $/server/měsíc."
freeBody = "Naše licencování <strong>Open-Core</strong> umožňuje až <strong>{{freeTierLimit}}</strong> uživatelů zdarma na server. Pro nepřerušený růst a přednostní přístup k našemu novému <strong>nástroji pro úpravu textu PDF</strong> doporučujeme plán Stirling Server – plné úpravy a <strong>neomezený počet míst</strong> za 99 $/server/měsíc."
freeBody = "Naše licencování <strong>Open-Core</strong> umožňuje až <strong>{{freeTierLimit}}</strong> uživatelů zdarma na server. Pro nepřerušované škálování doporučujeme plán Stirling Server - <strong>neomezený počet míst</strong> a <strong>podpora SSO</strong> za $99/server/měs."
[onboarding.desktopInstall]
title = "Stáhnout"
@@ -1221,9 +1221,9 @@ pdfaDigitalSignatureWarning = "PDF'en indeholder en digital signatur. Dette vil
fileFormat = "Filformat"
wordDoc = "Word-dokument"
wordDocExt = "Word-dokument (.docx)"
odtExt = "OpenDocument Text (.odt)"
odtExt = "OpenDocument-tekst (.odt)"
pptExt = "PowerPoint (.pptx)"
odpExt = "OpenDocument Presentation (.odp)"
odpExt = "OpenDocument-præsentation (.odp)"
txtExt = "Almindelig tekst (.txt)"
rtfExt = "Rich Text Format (.rtf)"
selectedFiles = "Valgte filer"
@@ -3790,7 +3790,7 @@ version = "Nuværende udgivelse"
title = "API-dokumentation"
header = "API-dokumentation"
desc = "Se og test Stirling PDF API-endpoints"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,dokumentation,swagger,endepunkter,udvikling"
[cookieBanner.popUp]
title = "Sådan bruger vi cookies"
@@ -5177,7 +5177,7 @@ upgrade = "Opgrader nu →"
freeTitle = "Serverlicens"
overLimitTitle = "Serverlicens påkrævet"
overLimitBody = "Vores licens tillader op til <strong>{{freeTierLimit}}</strong> brugere gratis pr. server. Du har <strong>{{overLimitUserCopy}}</strong> Stirling-brugere. For at fortsætte uden afbrydelser skal du opgradere til Stirling Server-abonnementet – <strong>ubegrænsede pladser</strong>, PDF-tekstredigering og fuld admin-kontrol for $99/server/md."
freeBody = "Vores <strong>Open-Core</strong>-licens tillader op til <strong>{{freeTierLimit}}</strong> brugere gratis pr. server. For at skalere uden afbrydelser og få tidlig adgang til vores nye <strong>PDF-tekstredigeringsværktøj</strong> anbefaler vi Stirling Server-planen – fuld redigering og <strong>ubegrænsede pladser</strong> for $99/server/md."
freeBody = "Vores <strong>Open-Core</strong>-licens tillader op til <strong>{{freeTierLimit}}</strong> brugere gratis pr. server. For at skalere uden afbrydelser anbefaler vi Stirling Server-planen – <strong>ubegrænsede pladser</strong> og <strong>SSO-understøttelse</strong> for $99/server/md."
[onboarding.desktopInstall]
title = "Download"
@@ -5177,7 +5177,7 @@ upgrade = "Jetzt upgraden →"
freeTitle = "Server-Lizenz"
overLimitTitle = "Server-Lizenz erforderlich"
overLimitBody = "Unsere Lizenz erlaubt bis zu <strong>{{freeTierLimit}}</strong> Nutzer pro Server kostenlos. Sie haben <strong>{{overLimitUserCopy}}</strong> Stirling-Nutzer. Um ohne Unterbrechung fortzufahren, upgraden Sie auf den Stirling-Server-Plan – <strong>unbegrenzte Plätze</strong>, PDF-Textbearbeitung und volle Admin-Kontrolle für $99/Server/Monat."
freeBody = "Unsere <strong>Open-Core</strong>-Lizenz erlaubt bis zu <strong>{{freeTierLimit}}</strong> Nutzer pro Server kostenlos. Für unterbrechungsfreies Skalieren und frühen Zugriff auf unser neues <strong>PDF-Textbearbeitungs-Tool</strong> empfehlen wir den Stirling-Server-Plan – volle Bearbeitung und <strong>unbegrenzte Plätze</strong> für $99/Server/Monat."
freeBody = "Unsere <strong>Open-Core</strong>-Lizenz erlaubt bis zu <strong>{{freeTierLimit}}</strong> Nutzern pro Server kostenlos. Um unterbrechungsfrei zu skalieren, empfehlen wir den Stirling Server-Plan - <strong>unbegrenzte Plätze</strong> und <strong>SSO-Unterstützung</strong> für $99/Server/Monat."
[onboarding.desktopInstall]
title = "Download"
@@ -3790,7 +3790,7 @@ version = "Τρέχουσα έκδοση"
title = "Τεκμηρίωση API"
header = "Τεκμηρίωση API"
desc = "Προβάλετε και δοκιμάστε τα endpoints του Stirling PDF API"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,τεκμηρίωση,swagger,τελικά σημεία,ανάπτυξη"
[cookieBanner.popUp]
title = "Πώς χρησιμοποιούμε τα cookies"
@@ -4482,7 +4482,7 @@ label = "Ενεργοποίηση προσκλήσεων μέσω email"
description = "Να επιτρέπεται στους διαχειριστές να προσκαλούν χρήστες μέσω email με αυτόματα παραγόμενους κωδικούς"
[admin.settings.mail.frontendUrl]
label = "Frontend URL"
label = "URL front-end"
description = "Βασικό URL για το frontend (π.χ. https://pdf.example.com). Χρησιμοποιείται για τη δημιουργία συνδέσμων πρόσκλησης στα email. Αφήστε κενό για χρήση του backend URL."
[admin.settings.legal]
@@ -5177,7 +5177,7 @@ upgrade = "Αναβάθμιση τώρα →"
freeTitle = "Άδεια διακομιστή"
overLimitTitle = "Απαιτείται άδεια διακομιστή"
overLimitBody = "Η αδειοδότηση μας επιτρέπει έως <strong>{{freeTierLimit}}</strong> χρήστες δωρεάν ανά διακομιστή. Έχετε <strong>{{overLimitUserCopy}}</strong> χρήστες Stirling. Για να συνεχίσετε χωρίς διακοπές, αναβαθμίστε στο πλάνο Stirling Server - <strong>απεριόριστες θέσεις</strong>, επεξεργασία κειμένου PDF και πλήρης έλεγχος διαχειριστή για $99/server/μήνα."
freeBody = "Η αδειοδότηση <strong>Open-Core</strong> μας επιτρέπει έως <strong>{{freeTierLimit}}</strong> χρήστες δωρεάν ανά διακομιστή. Για απρόσκοπτη κλιμάκωση και έγκαιρη πρόσβαση στο νέο <strong>εργαλείο επεξεργασίας κειμένου PDF</strong>, προτείνουμε το πλάνο Stirling Server - πλήρης επεξεργασία και <strong>απεριόριστες θέσεις</strong> για $99/server/μήνα."
freeBody = "Οι άδειες χρήσης <strong>Open-Core</strong> επιτρέπουν έως και <strong>{{freeTierLimit}}</strong> χρήστες δωρεάν ανά διακομιστή. Για απρόσκοπτη κλιμάκωση, προτείνουμε το πλάνο Stirling Server - <strong>απεριόριστες θέσεις</strong> και <strong>υποστήριξη SSO</strong> με $99/διακομιστή/μήνα."
[onboarding.desktopInstall]
title = "Λήψη"
+125 -3
View File
@@ -3036,6 +3036,91 @@ title = "Get Info on PDF"
header = "Get Info on PDF"
submit = "Get Info"
downloadJson = "Download JSON"
processing = "Extracting information..."
results = "Results"
noResults = "Run the tool to generate a report."
downloads = "Downloads"
noneDetected = "None detected"
indexTitle = "Index"
[getPdfInfo.report]
entryLabel = "Full information summary"
shortTitle = "PDF Information"
[getPdfInfo.sections]
metadata = "Metadata"
formFields = "Form Fields"
basicInfo = "Basic Info"
documentInfo = "Document Info"
compliance = "Compliance"
encryption = "Encryption"
permissions = "Permissions"
other = "Other"
perPageInfo = "Per Page Info"
tableOfContents = "Table of Contents"
[getPdfInfo.other]
attachments = "Attachments"
embeddedFiles = "Embedded Files"
javaScript = "JavaScript"
layers = "Layers"
structureTree = "StructureTree"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Size"
annotations = "Annotations"
images = "Images"
links = "Links"
fonts = "Fonts"
xobjects = "XObject Counts"
multimedia = "Multimedia"
[getPdfInfo.summary]
pages = "Pages"
fileSize = "File Size"
pdfVersion = "PDF Version"
language = "Language"
title = "PDF Summary"
author = "Author"
created = "Created"
modified = "Modified"
permsAll = "All Permissions Allowed"
permsRestricted = "{{count}} restrictions"
permsMixed = "Some permissions restricted"
hasCompliance = "Has compliance standards"
noCompliance = "No Compliance Standards"
basic = "Basic Information"
documentInfo = "Document Information"
securityTitle = "Security Status"
technical = "Technical"
overviewTitle = "PDF Overview"
[getPdfInfo.summary.security]
encrypted = "Encrypted PDF - Password protection present"
unencrypted = "Unencrypted PDF - No password protection"
[getPdfInfo.summary.tech]
images = "Images"
fonts = "Fonts"
formFields = "Form Fields"
embeddedFiles = "Embedded Files"
javaScript = "JavaScript"
layers = "Layers"
bookmarks = "Bookmarks"
multimedia = "Multimedia"
[getPdfInfo.summary.overview]
untitled = "an untitled document"
unknown = "Unknown Author"
text = "This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}})."
[getPdfInfo.error]
partial = "Some files could not be processed."
unexpected = "Unexpected error during extraction."
[getPdfInfo.status]
complete = "Extraction complete"
[extractPage]
tags = "extract"
@@ -3454,8 +3539,8 @@ signinTitle = "Please sign in"
ssoSignIn = "Login via Single Sign-on"
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create User Disabled"
oAuth2AdminBlockedUser = "Registration or logging in of non-registered users is currently blocked. Please contact the administrator."
oAuth2RequiresLicense = "OAuth/SSO login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan."
saml2RequiresLicense = "SAML login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan."
oAuth2RequiresLicense = "OAuth/SSO login requires a Server or Enterprise license. Please contact the administrator to upgrade your plan."
saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan."
maxUsersReached = "Maximum number of users reached for your current license. Please contact the administrator to upgrade your plan or add more seats."
oauth2RequestNotFound = "Authorization request not found"
oauth2InvalidUserInfoResponse = "Invalid User Info Response"
@@ -5178,7 +5263,7 @@ upgrade = "Upgrade now →"
freeTitle = "Server License"
overLimitTitle = "Server License Needed"
overLimitBody = "Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted and get early access to our new <strong>PDF text editing tool</strong>, we recommend the Stirling Server plan - full editing and <strong>unlimited seats</strong> for $99/server/mo."
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
[onboarding.desktopInstall]
title = "Download"
@@ -5283,6 +5368,31 @@ error = "Failed to update user status"
success = "User deleted successfully"
error = "Failed to delete user"
[workspace.people.changePassword]
action = "Change password"
title = "Change password"
subtitle = "Update the password for"
newPassword = "New password"
confirmPassword = "Confirm password"
placeholder = "Enter a new password"
confirmPlaceholder = "Re-enter the new password"
passwordRequired = "Please enter a new password"
passwordMismatch = "Passwords do not match"
generateRandom = "Generate secure password"
generatedPreview = "Generated password:"
copyTooltip = "Copy to clipboard"
copiedToClipboard = "Password copied to clipboard"
copyFailed = "Failed to copy password"
sendEmail = "Email the user about this change"
includePassword = "Include the new password in the email"
forcePasswordChange = "Force user to change password on next login"
emailUnavailable = "This user's email is not a valid email address. Notifications are disabled."
smtpDisabled = "Email notifications require SMTP to be enabled in settings."
notifyOnly = "An email will be sent without the password, letting the user know an admin changed it."
submit = "Update password"
success = "Password updated successfully"
error = "Failed to update password"
[workspace.people.emailInvite]
tab = "Email Invite"
description = "Type or paste in emails below, separated by commas. Users will receive login credentials via email."
@@ -5816,6 +5926,7 @@ subtitle = "Sign in with your Stirling account"
[setup.selfhosted]
title = "Sign in to Server"
subtitle = "Enter your server credentials"
link = "or connect to a self-hosted account"
[setup.server]
title = "Connect to Server"
@@ -5834,6 +5945,14 @@ description = "Enter the full URL of your self-hosted Stirling PDF server"
emptyUrl = "Please enter a server URL"
unreachable = "Could not connect to server"
testFailed = "Connection test failed"
configFetch = "Failed to fetch server configuration. Please check the URL and try again."
[setup.server.error.securityDisabled]
title = "Login Not Enabled"
body = "This server does not have login enabled. To connect to this server, you must enable authentication:"
step1 = "Set DOCKER_ENABLE_SECURITY=true in your environment"
step2 = "Or set security.enableLogin=true in settings.yml"
step3 = "Restart the server"
[setup.login]
title = "Sign In"
@@ -5906,6 +6025,7 @@ earlyAccess = "Early Access"
reset = "Reset Changes"
downloadJson = "Download JSON"
generatePdf = "Generate PDF"
saveChanges = "Save Changes"
[pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes"
@@ -5943,6 +6063,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
[pdfTextEditor.empty]
title = "No document loaded"
subtitle = "Load a PDF or JSON file to begin editing text content."
dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
[pdfTextEditor.welcomeBanner]
title = "Welcome to PDF Text Editor (Early Access)"
@@ -5177,7 +5177,7 @@ upgrade = "Actualizar ahora →"
freeTitle = "Licencia del servidor"
overLimitTitle = "Se necesita licencia de servidor"
overLimitBody = "Nuestra licencia permite hasta <strong>{{freeTierLimit}}</strong> usuarios gratis por servidor. Tiene <strong>{{overLimitUserCopy}}</strong> usuarios de Stirling. Para continuar sin interrupciones, actualice al plan Stirling Server: <strong>plazas ilimitadas</strong>, edición de texto PDF y control total de administración por 99 $/servidor/mes."
freeBody = "Nuestra licencia <strong>Open-Core</strong> permite hasta <strong>{{freeTierLimit}}</strong> usuarios gratis por servidor. Para escalar sin interrupciones y obtener acceso anticipado a nuestra nueva <strong>herramienta de edición de texto PDF</strong>, recomendamos el plan Stirling Server: edición completa y <strong>plazas ilimitadas</strong> por 99 $/servidor/mes."
freeBody = "Nuestra licencia <strong>Open-Core</strong> permite hasta <strong>{{freeTierLimit}}</strong> usuarios gratis por servidor. Para escalar sin interrupciones, recomendamos el plan Stirling Server - <strong>plazas ilimitadas</strong> y <strong>soporte SSO</strong> por $99/servidor/mes."
[onboarding.desktopInstall]
title = "Descargar"
@@ -5177,7 +5177,7 @@ upgrade = "Eguneratu orain →"
freeTitle = "Zerbitzari-lizentzia"
overLimitTitle = "Beharrezkoa da zerbitzari-lizentzia"
overLimitBody = "Gure lizentziak baimentzen ditu <strong>{{freeTierLimit}}</strong> erabiltzaile doan zerbitzari bakoitzeko. <strong>{{overLimitUserCopy}}</strong> Stirling erabiltzaile dituzu. Jarraitzeko etenik gabe, eguneratu Stirling Server planera - <strong>eserleku mugagabeak</strong>, PDF testu-edizioa, eta admin kontrol osoa $99/zerbitzari/hilean."
freeBody = "Gure <strong>Open-Core</strong> lizentziak <strong>{{freeTierLimit}}</strong> erabiltzaile arte baimentzen ditu doan zerbitzari bakoitzeko. Etenik gabe eskalatzeko eta gure <strong>PDF testu-edizio tresna</strong> berrirako sarbide goiztiarra lortzeko, gomendatzen dugu Stirling Server plana - edizio osoa eta <strong>eserleku mugagabeak</strong> $99/zerbitzari/hilean."
freeBody = "Gure <strong>Open-Core</strong> lizentziak zerbitzari bakoitzeko doan gehienez <strong>{{freeTierLimit}}</strong> erabiltzaile baimentzen ditu. Etenik gabe eskalatzeko, Stirling Server plana gomendatzen dugu - <strong>eserleku mugagabeak</strong> eta <strong>SSO euskarria</strong> $99/server/mo."
[onboarding.desktopInstall]
title = "Deskargatu"
@@ -4258,11 +4258,11 @@ label = "URL صادرکننده"
description = "Issuer URL ارائه‌دهنده OAuth2"
[admin.settings.connections.oauth2.clientId]
label = "Client ID"
label = "شناسهٔ کلاینت"
description = "Client ID مربوط به OAuth2 از ارائه‌دهنده شما"
[admin.settings.connections.oauth2.clientSecret]
label = "Client Secret"
label = "راز کلاینت"
description = "Client Secret مربوط به OAuth2 از ارائه‌دهنده شما"
[admin.settings.connections.oauth2.useAsUsername]
@@ -4293,7 +4293,7 @@ label = "ارائه‌دهنده"
description = "نام ارائه‌دهنده SAML2"
[admin.settings.connections.saml2.registrationId]
label = "Registration ID"
label = "شناسهٔ ثبت‌نام"
description = "شناسه ثبت‌نام SAML2"
[admin.settings.connections.saml2.autoCreateUser]
@@ -5177,7 +5177,7 @@ upgrade = "همین حالا ارتقا بده →"
freeTitle = "لایسنس سرور"
overLimitTitle = "نیاز به لایسنس سرور"
overLimitBody = "مجوز ما تا <strong>{{freeTierLimit}}</strong> کاربر رایگان به‌ازای هر سرور را مجاز می‌داند. شما <strong>{{overLimitUserCopy}}</strong> کاربر Stirling دارید. برای ادامه بدون وقفه، به پلن Stirling Server ارتقا دهید - <strong>صندلی نامحدود</strong>، ویرایش متن PDF و کنترل کامل ادمین با 99$ به‌ازای هر سرور در ماه."
freeBody = "مجوز <strong>Open-Core</strong> ما تا <strong>{{freeTierLimit}}</strong> کاربر رایگان به‌ازای هر سرور را مجاز می‌داند. برای مقیاس‌پذیری بدون وقفه و دسترسی زودهنگام به <strong>ابزار ویرایش متن PDF</strong> جدیدمان، پلن Stirling Server را پیشنهاد می‌کنیم - ویرایش کامل و <strong>صندلی نامحدود</strong> با 99$ به‌ازای هر سرور در ماه."
freeBody = "مجوز <strong>Open-Core</strong> ما به‌ازای هر سرور اجازهٔ استفادهٔ رایگان برای حداکثر <strong>{{freeTierLimit}}</strong> کاربر را می‌دهد. برای مقیاس‌دهی بدون وقفه، طرح Stirling Server را توصیه می‌کنیم - <strong>تعداد کاربران نامحدود</strong> و <strong>پشتیبانی از SSO</strong> با $99/سرور/ماه."
[onboarding.desktopInstall]
title = "دانلود"
@@ -363,7 +363,7 @@ connections = "Connexions"
[settings.licensingAnalytics]
title = "Licences et analyses"
plan = "Plan"
plan = "Forfait"
audit = "Audit"
usageAnalytics = "Analyses d'utilisation"
@@ -4550,7 +4550,7 @@ successMessage = "Fichier de licence téléversé et activé avec succès. Aucun
title = "Licence active"
file = "Source: Fichier de licence ({{path}})"
key = "Source: Clé de licence"
type = "Type: {{type}}"
type = "Type : {{type}}"
noInput = "Veuillez fournir une clé de licence ou téléverser un fichier de certificat"
success = "Succès"
@@ -5177,7 +5177,7 @@ upgrade = "Mettre à niveau maintenant →"
freeTitle = "Licence serveur"
overLimitTitle = "Licence serveur requise"
overLimitBody = "Notre licence autorise jusqu’à <strong>{{freeTierLimit}}</strong> utilisateurs gratuits par serveur. Vous avez <strong>{{overLimitUserCopy}}</strong> utilisateurs Stirling. Pour continuer sans interruption, passez au plan Stirling Server — <strong>places illimitées</strong>, édition de texte PDF et contrôle d’administration complet pour 99 $/serveur/mois."
freeBody = "Notre licence <strong>Open-Core</strong> autorise jusqu’à <strong>{{freeTierLimit}}</strong> utilisateurs gratuits par serveur. Pour évoluer sans interruption et accéder en avant-première à notre nouvel <strong>outil d’édition de texte PDF</strong>, nous recommandons le plan Stirling Server — édition complète et <strong>places illimitées</strong> pour 99 $/serveur/mois."
freeBody = "Notre régime de licence <strong>Open-Core</strong> autorise jusqu'à <strong>{{freeTierLimit}}</strong> utilisateurs gratuitement par serveur. Pour évoluer sans interruption, nous recommandons le forfait Stirling Server - <strong>places illimitées</strong> et <strong>prise en charge du SSO</strong> pour 99 $/serveur/mois."
[onboarding.desktopInstall]
title = "Télécharger"
@@ -5892,7 +5892,7 @@ paragraph = "Page de paragraphe"
sparse = "Texte clairsemé"
[pdfTextEditor.groupingMode]
auto = "Auto"
auto = "Automatique"
paragraph = "Paragraphe"
singleLine = "Ligne unique"
@@ -5177,7 +5177,7 @@ upgrade = "Uasghrádaigh anois →"
freeTitle = "Ceadúnas Freastalaí"
overLimitTitle = "Ceadúnas Freastalaí de dhíth"
overLimitBody = "Ceadaíonn ár gceadúnú suas le <strong>{{freeTierLimit}}</strong> úsáideoir in aisce in aghaidh freastalaí. Tá <strong>{{overLimitUserCopy}}</strong> úsáideoir Stirling agat. Chun leanúint gan bhriseadh, uasghrádaigh go plean Freastalaí Stirling - <strong>suíocháin neamhtheoranta</strong>, eagarthóireacht téacs PDF, agus lánrialú riaracháin ar $99/freastalaí/mí."
freeBody = "Ceadaíonn ár gceadúnú <strong>Open-Core</strong> suas le <strong>{{freeTierLimit}}</strong> úsáideoir in aisce in aghaidh freastalaí. Chun méadú gan bhriseadh agus rochtain luath a fháil ar ár <strong>uirlis eagarthóireachta téacs PDF</strong> nua, molaimid Plean Freastalaí Stirling - eagarthóireacht iomlán agus <strong>suíocháin neamhtheoranta</strong> ar $99/freastalaí/mí."
freeBody = "Ceadaíonn ár gceadúnú <strong>Open-Core</strong> suas le <strong>{{freeTierLimit}}</strong> úsáideoirí saor in aisce in aghaidh an fhreastalaí. Chun scálú gan bhriseadh, molaimid an plean Stirling Server - <strong>suíocháin neamhtheoranta</strong> agus <strong>tacaíocht SSO</strong> ar $99/server/mo."
[onboarding.desktopInstall]
title = "Íoslódáil"
@@ -5333,8 +5333,8 @@ emailDisabled = "Teastaíonn cumraíocht SMTP agus mail.enableInvites=true sna s
[workspace.people.license]
users = "úsáideoirí"
availableSlots = "Áiteanna Ar Fáil"
grandfathered = "Grandfathered"
grandfatheredShort = "{{count}} grandfathered"
grandfathered = "Ceadaithe roimhe seo"
grandfatheredShort = "{{count}} ceadaithe roimhe seo"
fromLicense = "ón gceadúnas"
slotsAvailable = "{{count}} áit(í) úsáideora ar fáil"
noSlotsAvailable = "Níl aon áiteanna ar fáil"
+10 -10
View File
@@ -834,7 +834,7 @@ title = "PDF हस्ताक्षर सत्यापित करें"
desc = "PDF दस्तावेजों में डिजिटल हस्ताक्षर और प्रमाणपत्रों को सत्यापित करें"
[home.swagger]
tags = "API,documentation,test"
tags = "API,दस्तावेज़ीकरण,परीक्षण"
title = "API दस्तावेज़ीकरण"
desc = "API दस्तावेज़ देखें और एंडपॉइंट टेस्ट करें"
@@ -883,7 +883,7 @@ title = "रंग बदलें/उलटें"
desc = "PDF दस्तावेज़ों में रंगों को प्रतिस्थापित या उलटें"
[home.devApi]
tags = "API,development,documentation"
tags = "API,विकास,दस्तावेज़ीकरण"
title = "API"
desc = "API दस्तावेज़ के लिए लिंक"
@@ -922,7 +922,7 @@ title = "PDF टेक्स्ट एडिटर"
desc = "PDF फ़ाइलों के भीतर मौजूदा टेक्स्ट और इमेज संपादित करें"
[home.addText]
tags = "text,annotation,label"
tags = "पाठ,टिप्पणी,लेबल"
title = "टेक्स्ट जोड़ें"
desc = "अपने PDF में कहीं भी कस्टम टेक्स्ट जोड़ें"
@@ -1840,7 +1840,7 @@ title = "उन्नत"
tags = "कम्प्रेस,छोटा,छोटा"
[unlockPDFForms]
tags = "remove,delete,form,field,readonly"
tags = "हटाएं,मिटाएं,फॉर्म,फ़ील्ड,रीड-ओनली"
title = "फॉर्म फ़ील्ड से Read-Only हटाएं"
header = "PDF फॉर्म अनलॉक करें"
submit = "Remove"
@@ -2747,7 +2747,7 @@ submit = "जमा करें"
failed = "मल्टी-पृष्ठ लेआउट बनाते समय त्रुटि हुई।"
[bookletImposition]
tags = "booklet,imposition,printing,binding,folding,signature"
tags = "बुकलेट,इम्पोज़िशन,प्रिंटिंग,बाइंडिंग,फोल्डिंग,सिग्नेचर"
title = "बुकलेट इम्पोज़िशन"
header = "बुकलेट इम्पोज़िशन"
submit = "बुकलेट बनाएँ"
@@ -2846,7 +2846,7 @@ scaleFactor = "एक पृष्ठ का ज़ूम स्तर (क्
submit = "जमा करें"
[adjustPageScale]
tags = "resize,modify,dimension,adapt"
tags = "आकार बदलें,संशोधित करें,आयाम,अनुकूलित करें"
title = "पृष्ठ स्केल समायोजित करें"
header = "पृष्ठ स्केल समायोजित करें"
submit = "पृष्ठ स्केल समायोजित करें"
@@ -3396,7 +3396,7 @@ certHint = "कस्टम ट्रस्ट स्रोत के विर
title = "सत्यापन सेटिंग्स"
[replaceColor]
tags = "Replace Colour,Page operations,Back end,server side"
tags = "रंग बदलें,पृष्ठ संचालन,Back end,server side"
[replaceColor.labels]
settings = "सेटिंग्स"
@@ -3790,7 +3790,7 @@ version = "वर्तमान रिलीज़"
title = "API दस्तावेज़ीकरण"
header = "API दस्तावेज़ीकरण"
desc = "Stirling PDF API एंडपॉइंट्स देखें और परीक्षण करें"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,दस्तावेज़ीकरण,swagger,endpoints,विकास"
[cookieBanner.popUp]
title = "हम कुकीज़ का उपयोग कैसे करते हैं"
@@ -5177,7 +5177,7 @@ upgrade = "अभी अपग्रेड करें →"
freeTitle = "सर्वर लाइसेंस"
overLimitTitle = "सर्वर लाइसेंस आवश्यक"
overLimitBody = "हमारा लाइसेंसिंग प्रति सर्वर अधिकतम <strong>{{freeTierLimit}}</strong> उपयोगकर्ताओं को मुफ्त अनुमति देता है। आपके पास <strong>{{overLimitUserCopy}}</strong> Stirling उपयोगकर्ता हैं। बिना बाधा के जारी रखने के लिए, Stirling Server प्लान में अपग्रेड करें - <strong>अनलिमिटेड सीट्स</strong>, PDF टेक्स्ट एडिटिंग, और पूर्ण एडमिन नियंत्रण $99/server/mo में।"
freeBody = "हमारा <strong>Open-Core</strong> लाइसेंसिंग प्रति सर्वर अधिकतम <strong>{{freeTierLimit}}</strong> उपयोगकर्ताओं को मुफ्त अनुमति देता है। बिना बाधा स्केल करने और हमारे नए <strong>PDF टेक्स्ट एडिटिंग टूल</strong> की प्रारंभिक पहुँच पाने के लिए हम Stirling Server प्लान की सलाह देते हैं - पूर्ण एडिटिंग और <strong>अनलिमिटेड सीट्स</strong> $99/server/mo में।"
freeBody = "हमारा <strong>Open-Core</strong> लाइसेंसिंग प्रति सर्वर अधिकतम <strong>{{freeTierLimit}}</strong> उपयोगकर्ताओं को निःशुल्क अनुमति देता है। बिना रुकावट स्केल करने के लिए, हम Stirling Server प्लान की अनुशंसा करते हैं - <strong>असीमित सीटें</strong> और <strong>SSO समर्थन</strong> $99/सर्वर/माह पर।"
[onboarding.desktopInstall]
title = "डाउनलोड"
@@ -6005,7 +6005,7 @@ insufficientPermissions = "आपके पास यह क्रिया क
[addText]
title = "टेक्स्ट जोड़ें"
header = "PDFs में टेक्स्ट जोड़ें"
tags = "text,annotation,label"
tags = "पाठ,टिप्पणी,लेबल"
applySignatures = "टेक्स्ट लागू करें"
[addText.text]
@@ -5177,7 +5177,7 @@ upgrade = "Nadogradi odmah →"
freeTitle = "Poslužiteljska licenca"
overLimitTitle = "Potrebna poslužiteljska licenca"
overLimitBody = "Naše licenciranje dopušta do <strong>{{freeTierLimit}}</strong> korisnika besplatno po poslužitelju. Imate <strong>{{overLimitUserCopy}}</strong> Stirling korisnika. Za nesmetan nastavak, nadogradite na Stirling Server plan - <strong>neograničena mjesta</strong>, uređivanje teksta u PDF-u i puna admin kontrola za $99/server/mo."
freeBody = "Naše <strong>Open-Core</strong> licenciranje dopušta do <strong>{{freeTierLimit}}</strong> korisnika besplatno po poslužitelju. Za nesmetano skaliranje i rani pristup našem novom <strong>alatu za uređivanje teksta u PDF-u</strong>, preporučujemo Stirling Server plan - potpuno uređivanje i <strong>neograničena mjesta</strong> za $99/server/mo."
freeBody = "Naše licenciranje <strong>Open-Core</strong> omogućuje do <strong>{{freeTierLimit}}</strong> korisnika besplatno po poslužitelju. Za neometano skaliranje preporučujemo Stirling Server plan - <strong>neograničena mjesta</strong> i <strong>podrška za SSO</strong> za $99/poslužitelj/mj."
[onboarding.desktopInstall]
title = "Preuzimanje"
@@ -5177,7 +5177,7 @@ upgrade = "Frissítés most →"
freeTitle = "Szerverlicenc"
overLimitTitle = "Szerverlicenc szükséges"
overLimitBody = "Licencelésünk szerverenként legfeljebb <strong>{{freeTierLimit}}</strong> felhasználót enged ingyen. Önnek <strong>{{overLimitUserCopy}}</strong> Stirling felhasználója van. A zavartalan használathoz váltson a Stirling Server csomagra – <strong>korlátlan hely</strong>, PDF szövegszerkesztés és teljes adminisztrátori vezérlés $99/szerver/hó áron."
freeBody = "Az <strong>Open-Core</strong> licencelésünk szerverenként legfeljebb <strong>{{freeTierLimit}}</strong> felhasználót enged ingyen. A zavartalan bővüléshez és az új <strong>PDF szövegszerkesztő eszköz</strong> korai eléréséhez a Stirling Server csomagot ajánljuk – teljes szerkesztés és <strong>korlátlan hely</strong> $99/szerver/hó áron."
freeBody = "A <strong>Open-Core</strong> licencünk szerverenként legfeljebb <strong>{{freeTierLimit}}</strong> felhasználót engedélyez ingyenesen. A zökkenőmentes skálázáshoz a Stirling Server csomagot ajánljuk - <strong>korlátlan felhasználó</strong> és <strong>SSO támogatás</strong> $99/szerver/hó."
[onboarding.desktopInstall]
title = "Letöltés"
@@ -5177,7 +5177,7 @@ upgrade = "Upgrade sekarang →"
freeTitle = "Lisensi Server"
overLimitTitle = "Perlu Lisensi Server"
overLimitBody = "Lisensi kami mengizinkan hingga <strong>{{freeTierLimit}}</strong> pengguna gratis per server. Anda memiliki <strong>{{overLimitUserCopy}}</strong> pengguna Stirling. Untuk terus berjalan tanpa gangguan, upgrade ke paket Stirling Server - <strong>kursi tanpa batas</strong>, pengeditan teks PDF, dan kontrol admin penuh seharga $99/server/bulan."
freeBody = "Lisensi <strong>Open-Core</strong> kami mengizinkan hingga <strong>{{freeTierLimit}}</strong> pengguna gratis per server. Untuk skala tanpa hambatan dan mendapatkan akses awal ke <strong>alat pengeditan teks PDF</strong> baru kami, kami sarankan paket Stirling Server - pengeditan penuh dan <strong>kursi tanpa batas</strong> seharga $99/server/bulan."
freeBody = "Lisensi <strong>Open-Core</strong> kami mengizinkan hingga <strong>{{freeTierLimit}}</strong> pengguna gratis per server. Untuk meningkatkan skala tanpa gangguan, kami merekomendasikan paket Stirling Server - <strong>pengguna tanpa batas</strong> dan <strong>dukungan SSO</strong> seharga $99/server/mo."
[onboarding.desktopInstall]
title = "Unduh"
@@ -5433,7 +5433,7 @@ hideComparison = "Sembunyikan Perbandingan Fitur"
featureComparison = "Perbandingan Fitur"
from = "Mulai"
perMonth = "/bulan"
perSeat = "/seat"
perSeat = "/pengguna"
withServer = "+ Paket Server"
licensedSeats = "Berlisensi: {{count}} seat"
includedInCurrent = "Termasuk dalam Paket Anda"
@@ -5594,7 +5594,7 @@ modalTitle = "Mulai - {{planName}}"
title = "Pilih Periode Penagihan"
savingsNote = "Hemat {{percent}}% dengan penagihan tahunan"
basePrice = "Harga Dasar"
seatPrice = "Per Seat"
seatPrice = "Per Pengguna"
totalForSeats = "Total ({{count}} seat)"
selectMonthly = "Pilih Bulanan"
selectYearly = "Pilih Tahunan"
@@ -4176,7 +4176,7 @@ description = "Traccia azioni degli utenti ed eventi di sistema per conformità
[admin.settings.security.audit.level]
label = "Livello audit"
description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE"
description = "0=SPENTO, 1=BASE, 2=STANDARD, 3=DETTAGLIATO"
[admin.settings.security.audit.retentionDays]
label = "Conservazione audit (giorni)"
@@ -5177,7 +5177,7 @@ upgrade = "Esegui upgrade ora →"
freeTitle = "Licenza server"
overLimitTitle = "Licenza server necessaria"
overLimitBody = "La nostra licenza consente fino a <strong>{{freeTierLimit}}</strong> utenti gratuiti per server. Hai <strong>{{overLimitUserCopy}}</strong> utenti Stirling. Per continuare senza interruzioni, esegui l'upgrade al piano Stirling Server - <strong>posti illimitati</strong>, modifica del testo PDF e pieno controllo admin a $99/server/mese."
freeBody = "La nostra licenza <strong>Open-Core</strong> consente fino a <strong>{{freeTierLimit}}</strong> utenti gratuiti per server. Per scalare senza interruzioni e ottenere accesso anticipato al nuovo <strong>strumento di modifica testo PDF</strong>, consigliamo il piano Stirling Server - modifica completa e <strong>posti illimitati</strong> a $99/server/mese."
freeBody = "La nostra licenza <strong>Open-Core</strong> consente fino a <strong>{{freeTierLimit}}</strong> utenti gratuiti per server. Per scalare senza interruzioni, consigliamo il piano Stirling Server - <strong>posti illimitati</strong> e <strong>supporto SSO</strong> a $99/server/mese."
[onboarding.desktopInstall]
title = "Download"
@@ -5177,7 +5177,7 @@ upgrade = "今すぐアップグレード →"
freeTitle = "サーバーライセンス"
overLimitTitle = "サーバーライセンスが必要です"
overLimitBody = "当社のライセンスでは、サーバーごとに <strong>{{freeTierLimit}}</strong> ユーザーまで無料です。現在 <strong>{{overLimitUserCopy}}</strong> の Stirling ユーザーがいます。中断なく利用を続けるには、Stirling Server プランにアップグレードしてください - <strong>無制限席数</strong>、PDF テキスト編集、完全な管理機能が $99/サーバー/月 です。"
freeBody = "当社の <strong>オープンコア</strong> ライセンスでは、サーバーごとに最大 <strong>{{freeTierLimit}}</strong> ユーザーまで無料です。中断なく拡張し、新しい <strong>PDF テキスト編集ツール</strong> に早期アクセスするには、Stirling Server プランをお勧めします。完全編集と <strong>無制限席数</strong> が $99/サーバー/月 です。"
freeBody = "当社の<strong>Open-Core</strong>ライセンスでは、サーバーごとに最大<strong>{{freeTierLimit}}</strong>ユーザーまで無料でご利用いただけます。中断なくスケールするには、Stirling Server プランをおすすめします - <strong>無制限の席数</strong>と<strong>SSO サポート</strong>で $99/サーバー/月。"
[onboarding.desktopInstall]
title = "ダウンロード"
@@ -5177,7 +5177,7 @@ upgrade = "지금 업그레이드 →"
freeTitle = "서버 라이선스"
overLimitTitle = "서버 라이선스 필요"
overLimitBody = "당사의 라이선스는 서버당 무료로 최대 <strong>{{freeTierLimit}}</strong>명의 사용자를 허용합니다. 현재 <strong>{{overLimitUserCopy}}</strong>명의 Stirling 사용자가 있습니다. 중단 없이 계속 사용하려면 Stirling Server 플랜으로 업그레이드하세요 - <strong>무제한 좌석</strong>, PDF 텍스트 편집, 전체 관리자 제어 제공, $99/서버/월."
freeBody = "당사의 <strong>Open-Core</strong> 라이선스는 서버당 최대 <strong>{{freeTierLimit}}</strong>명의 사용자를 무료로 허용합니다. 중단 없이 확장하고 새로운 <strong>PDF 텍스트 편집 도구</strong>에 조기 액세스하려면 Stirling Server 플랜을 권장합니다 - 전체 편집과 <strong>무제한 좌석</strong>을 $99/서버/월에 제공합니다."
freeBody = "당사의 <strong>Open-Core</strong> 라이선스는 서버당 최대 <strong>{{freeTierLimit}}</strong>명의 사용자를 무료로 허용합니다. 중단 없이 확장하려면 Stirling Server 플랜을 권장합니다 - <strong>무제한 좌석</strong> 및 <strong>SSO 지원</strong>, $99/서버/월."
[onboarding.desktopInstall]
title = "다운로드"
@@ -5177,7 +5177,7 @@ upgrade = "ഇപ്പോൾ അപ്‌ഗ്രേഡ് ചെയ്യു
freeTitle = "സെർവർ ലൈസൻസ്"
overLimitTitle = "സെർവർ ലൈസൻസ് ആവശ്യമാണ്"
overLimitBody = "ഞങ്ങളുടെ ലൈസൻസിംഗ് ഓരോ സെർവർക്കും പരമാവധി <strong>{{freeTierLimit}}</strong> ഉപയോക്താക്കളെ സൗജന്യമായി അനുവദിക്കുന്നു. നിങ്ങള്ക്ക് <strong>{{overLimitUserCopy}}</strong> Stirling ഉപയോക്താക്കളുണ്ട്. തടസ്സമില്ലാതെ തുടരാൻ, Stirling Server പ്ലാനിലേക്ക് അപ്‌ഗ്രേഡ് ചെയ്യുക - <strong>unlimited seats</strong>, PDF text editing, പൂർണ്ണ അഡ്മിൻ നിയന്ത്രണം, $99/server/mo."
freeBody = "ഞങ്ങളുടെ <strong>Open-Core</strong> ലൈസൻസിംഗ് ഓരോ സെർവർക്കും പരമാവധി <strong>{{freeTierLimit}}</strong> ഉപയോക്താക്കളെ സൗജന്യമായി അനുവദിക്കുന്നു. തടസ്സമില്ലാതെ സ്കെയിൽ ചെയ്യാനും പുതിയ <strong>PDF text editing tool</strong> ന് മുൻകാല ആക്സസ് നേടാനും, Stirling Server പ്ലാൻ ഞങ്ങൾ ശുപാർശ ചെയ്യുന്നു - പൂർണ്ണ എഡിറ്റിംഗും <strong>unlimited seats</strong> ഉം $99/server/mo."
freeBody = "ഞങ്ങളുടെ <strong>Open-Core</strong> ലൈസൻസിംഗ് ഓരോ സെർവർക്കും പരമാവധി <strong>{{freeTierLimit}}</strong> ഉപയോക്താക്കളെ സൗജന്യമായി അനുവദിക്കുന്നു. തടസ്സമില്ലാതെ സ്‌കെയിൽ ചെയ്യാൻ, ഞങ്ങൾ Stirling Server പ്ലാൻ ശുപാർശ ചെയ്യുന്നു - <strong>പരിമിതിയില്ലാത്ത സീറ്റുകൾ</strong>യും <strong>SSO പിന്തുണ</strong>യും for $99/server/mo."
[onboarding.desktopInstall]
title = "ഡൗൺലോഡ്"
@@ -5177,7 +5177,7 @@ upgrade = "Nu upgraden →"
freeTitle = "Serverlicentie"
overLimitTitle = "Serverlicentie vereist"
overLimitBody = "Onze licentie staat tot <strong>{{freeTierLimit}}</strong> gebruikers gratis per server toe. Je hebt <strong>{{overLimitUserCopy}}</strong> Stirling-gebruikers. Om zonder onderbreking door te gaan, upgrade naar het Stirling Server-plan - <strong>onbeperkte plaatsen</strong>, PDF-tekstbewerking en volledige admincontrole voor $99/server/maand."
freeBody = "Onze <strong>Open-Core</strong>-licentie staat tot <strong>{{freeTierLimit}}</strong> gebruikers gratis per server toe. Om ononderbroken te schalen en vroege toegang te krijgen tot onze nieuwe <strong>PDF-tekstbewerkingstool</strong>, raden we het Stirling Server-plan aan - volledige bewerking en <strong>onbeperkte plaatsen</strong> voor $99/server/maand."
freeBody = "Onze <strong>Open-Core</strong>-licentie staat tot <strong>{{freeTierLimit}}</strong> gebruikers per server gratis toe. Om ononderbroken op te schalen, raden we het Stirling Server-abonnement aan - <strong>onbeperkte plaatsen</strong> en <strong>SSO-ondersteuning</strong> voor $99/server/maand."
[onboarding.desktopInstall]
title = "Downloaden"
@@ -5177,7 +5177,7 @@ upgrade = "Oppgrader nå →"
freeTitle = "Serverlisens"
overLimitTitle = "Serverlisens kreves"
overLimitBody = "Lisensieringen vår tillater opptil <strong>{{freeTierLimit}}</strong> brukere gratis per server. Du har <strong>{{overLimitUserCopy}}</strong> Stirling-brukere. For å fortsette uten avbrudd, oppgrader til Stirling Server-planen – <strong>ubegrensede plasser</strong>, PDF-tekstredigering og full admin-kontroll for $99/server/mnd."
freeBody = "Vår <strong>Open-Core</strong>-lisensiering tillater opptil <strong>{{freeTierLimit}}</strong> brukere gratis per server. For å skalere uten avbrudd og få tidlig tilgang til vårt nye <strong>PDF-tekstredigeringsverktøy</strong>, anbefaler vi Stirling Server-planen – full redigering og <strong>ubegrensede plasser</strong> for $99/server/mnd."
freeBody = "Vår <strong>Open-Core</strong>-lisensiering tillater opptil <strong>{{freeTierLimit}}</strong> brukere gratis per server. For å skalere uten avbrudd anbefaler vi Stirling Server-planen - <strong>ubegrensede plasser</strong> og <strong>SSO-støtte</strong> for $99/server/mnd."
[onboarding.desktopInstall]
title = "Last ned"
@@ -568,7 +568,7 @@ loading = "Ładowanie..."
failedToLoad = "Nie udało się załadować danych punktów końcowych. Spróbuj odświeżyć."
home = "Strona główna"
login = "Logowanie"
top = "Top"
top = "Najlepsze"
numberOfVisits = "Liczba wizyt"
visitsTooltip = "Wizyty: {0} ({1}% całości)"
retry = "Spróbuj ponownie"
@@ -1225,7 +1225,7 @@ odtExt = "Tekst OpenDocument (.odt)"
pptExt = "PowerPoint (.pptx)"
odpExt = "Prezentacja OpenDocument (.odp)"
txtExt = "Tekst niesformatowany (.txt)"
rtfExt = "Rich Text Format (.rtf)"
rtfExt = "Format RTF (.rtf)"
selectedFiles = "Wybrane pliki"
noFileSelected = "Nie wybrano pliku. Użyj panelu plików, aby dodać pliki."
convertFiles = "Konwertuj pliki"
@@ -5177,7 +5177,7 @@ upgrade = "Ulepsz teraz →"
freeTitle = "Licencja serwera"
overLimitTitle = "Wymagana licencja serwera"
overLimitBody = "Nasza licencja pozwala na maks. <strong>{{freeTierLimit}}</strong> użytkowników bez opłat na serwer. Masz <strong>{{overLimitUserCopy}}</strong> użytkowników Stirling. Aby kontynuować bez przerw, przejdź na plan Stirling Server – <strong>nielimitowane miejsca</strong>, edycja tekstu PDF i pełna kontrola administracyjna za 99 USD/serwer/mies."
freeBody = "Nasza licencja <strong>Open-Core</strong> pozwala na maks. <strong>{{freeTierLimit}}</strong> użytkowników bez opłat na serwer. Aby skalować bez przerw i uzyskać wczesny dostęp do nowego <strong>narzędzia edycji tekstu PDF</strong>, polecamy plan Stirling Server – pełna edycja i <strong>nielimitowane miejsca</strong> za 99 USD/serwer/mies."
freeBody = "Nasza licencja <strong>Open-Core</strong> pozwala na maksymalnie <strong>{{freeTierLimit}}</strong> użytkowników bezpłatnie na serwer. Aby skalować bez zakłóceń, zalecamy plan Stirling Server - <strong>nielimitowana liczba miejsc</strong> i <strong>obsługa SSO</strong> za $99/serwer/mies."
[onboarding.desktopInstall]
title = "Pobierz"
@@ -5586,7 +5586,7 @@ emailInvalid = "Wpisz poprawny adres e‑mail"
title = "Podaj e‑mail"
description = "Użyjemy go do wysłania klucza licencyjnego i rachunków."
emailLabel = "Adres e‑mail"
emailPlaceholder = "your@email.com"
emailPlaceholder = "twoj@email.com"
continue = "Kontynuuj"
modalTitle = "Zaczynamy – {{planName}}"
@@ -5177,7 +5177,7 @@ upgrade = "Fazer upgrade agora →"
freeTitle = "Licença do servidor"
overLimitTitle = "Necessária licença do servidor"
overLimitBody = "Nossa licença permite até <strong>{{freeTierLimit}}</strong> usuários grátis por servidor. Você tem <strong>{{overLimitUserCopy}}</strong> usuários do Stirling. Para continuar sem interrupções, faça upgrade para o plano Stirling Server - <strong>assentos ilimitados</strong>, edição de texto em PDF e controle total de admin por US$ 99/servidor/mês."
freeBody = "Nossa licença <strong>Open-Core</strong> permite até <strong>{{freeTierLimit}}</strong> usuários grátis por servidor. Para escalar sem interrupções e ter acesso antecipado à nova <strong>ferramenta de edição de texto em PDF</strong>, recomendamos o plano Stirling Server - edição completa e <strong>assentos ilimitados</strong> por US$ 99/servidor/mês."
freeBody = "Nossa licença <strong>Open-Core</strong> permite até <strong>{{freeTierLimit}}</strong> usuários gratuitos por servidor. Para escalar sem interrupções, recomendamos o plano Stirling Server - <strong>assentos ilimitados</strong> e <strong>suporte a SSO</strong> por US$ 99/servidor/mês."
[onboarding.desktopInstall]
title = "Download"
@@ -5586,7 +5586,7 @@ emailInvalid = "Digite um endereço de e-mail válido"
title = "Informe seu e-mail"
description = "Usaremos isso para enviar sua chave de licença e recibos."
emailLabel = "Endereço de e-mail"
emailPlaceholder = "your@email.com"
emailPlaceholder = "seu@email.com"
continue = "Continuar"
modalTitle = "Começar - {{planName}}"
@@ -5177,7 +5177,7 @@ upgrade = "Atualizar agora →"
freeTitle = "Licença do servidor"
overLimitTitle = "É necessária licença de servidor"
overLimitBody = "A nossa licença permite até <strong>{{freeTierLimit}}</strong> utilizadores gratuitos por servidor. Tem <strong>{{overLimitUserCopy}}</strong> utilizadores Stirling. Para continuar sem interrupções, atualize para o plano Stirling Server - <strong>lugares ilimitados</strong>, edição de texto em PDF e controlo total de administração por $99/servidor/mês."
freeBody = "A nossa licença <strong>Open-Core</strong> permite até <strong>{{freeTierLimit}}</strong> utilizadores gratuitos por servidor. Para escalar sem interrupções e obter acesso antecipado à nossa nova <strong>ferramenta de edição de texto PDF</strong>, recomendamos o plano Stirling Server - edição completa e <strong>lugares ilimitados</strong> por $99/servidor/mês."
freeBody = "O nosso licenciamento <strong>Open-Core</strong> permite até <strong>{{freeTierLimit}}</strong> utilizadores gratuitos por servidor. Para escalar sem interrupções, recomendamos o plano Stirling Server - <strong>lugares ilimitados</strong> e <strong>suporte SSO</strong> por $99/servidor/mês."
[onboarding.desktopInstall]
title = "Transferir"
@@ -3948,7 +3948,7 @@ files = "Fișiere"
activity = "Jurnal"
help = "Ajutor"
account = "Cont"
config = "Config"
config = "Configurare"
settings = "Setări"
adminSettings = "Setări admin"
allTools = "All Tools"
@@ -4176,7 +4176,7 @@ description = "Urmărește acțiunile utilizatorilor și evenimentele de sistem
[admin.settings.security.audit.level]
label = "Nivel audit"
description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE"
description = "0=OPRIT, 1=DE BAZĂ, 2=STANDARD, 3=DETALIAT"
[admin.settings.security.audit.retentionDays]
label = "Păstrare audit (zile)"
@@ -5177,7 +5177,7 @@ upgrade = "Fă upgrade acum →"
freeTitle = "Licență server"
overLimitTitle = "Necesită licență de server"
overLimitBody = "Politica noastră de licențiere permite până la <strong>{{freeTierLimit}}</strong> utilizatori gratuit per server. Ai <strong>{{overLimitUserCopy}}</strong> utilizatori Stirling. Pentru a continua fără întreruperi, fă upgrade la planul Stirling Server - <strong>locuri nelimitate</strong>, editare text PDF și control complet de admin pentru $99/server/lună."
freeBody = "Licențierea noastră <strong>Open-Core</strong> permite până la <strong>{{freeTierLimit}}</strong> utilizatori gratuit per server. Pentru a scala fără întreruperi și a primi acces timpuriu la noul nostru <strong>instrument de editare text PDF</strong>, recomandăm planul Stirling Server - editare completă și <strong>locuri nelimitate</strong> pentru $99/server/lună."
freeBody = "Licențierea noastră <strong>Open-Core</strong> permite până la <strong>{{freeTierLimit}}</strong> utilizatori gratuit per server. Pentru scalare fără întreruperi, recomandăm planul Stirling Server - <strong>locuri nelimitate</strong> și <strong>suport SSO</strong> pentru $99/server/lună."
[onboarding.desktopInstall]
title = "Descărcare"
@@ -5984,7 +5984,7 @@ warnings = "Avertizări"
suggestions = "Note"
currentPageFonts = "Fonturi pe această pagină"
allFonts = "Toate fonturile"
fallback = "fallback"
fallback = "rezervă"
missing = "lipsește"
perfectMessage = "Toate fonturile pot fi redate perfect."
warningMessage = "Unele fonturi pot să nu fie redate corect."
@@ -5177,7 +5177,7 @@ upgrade = "Обновить сейчас →"
freeTitle = "Серверная лицензия"
overLimitTitle = "Требуется серверная лицензия"
overLimitBody = "Наша лицензия допускает до <strong>{{freeTierLimit}}</strong> пользователей бесплатно на сервер. У вас <strong>{{overLimitUserCopy}}</strong> пользователей Stirling. Чтобы продолжить без перебоев, перейдите на тариф Stirling Server — <strong>неограниченные места</strong>, редактирование текста в PDF и полный админ‑контроль за $99/server/mo."
freeBody = "Наша лицензия <strong>Open-Core</strong> допускает до <strong>{{freeTierLimit}}</strong> пользователей бесплатно на сервер. Чтобы масштабироваться без ограничений и раньше получить доступ к новому <strong>инструменту редактирования текста в PDF</strong>, рекомендуем тариф Stirling Server — полный редактор и <strong>неограниченные места</strong> за $99/server/mo."
freeBody = "Наша лицензия <strong>Open-Core</strong> позволяет бесплатно использовать до <strong>{{freeTierLimit}}</strong> пользователей на сервер. Для бесшовного масштабирования мы рекомендуем план Stirling Server - <strong>неограниченное число мест</strong> и <strong>поддержка SSO</strong> за $99/сервер/мес."
[onboarding.desktopInstall]
title = "Скачать"
@@ -5177,7 +5177,7 @@ upgrade = "Upgradovať teraz →"
freeTitle = "Serverová licencia"
overLimitTitle = "Potrebná serverová licencia"
overLimitBody = "Naše licencovanie povoľuje až <strong>{{freeTierLimit}}</strong> používateľov zdarma na server. Máte <strong>{{overLimitUserCopy}}</strong> používateľov Stirling. Ak chcete pokračovať bez prerušenia, prejdite na plán Stirling Server - <strong>neobmedzené miesta</strong>, úpravy textu PDF a plná správa pre $99/server/mo."
freeBody = "Naše licencovanie <strong>Open-Core</strong> povoľuje až <strong>{{freeTierLimit}}</strong> používateľov zdarma na server. Ak chcete škálovať bez prerušenia a získať skorý prístup k nášmu novému <strong>nástroju na úpravu textu PDF</strong>, odporúčame plán Stirling Server - plné úpravy a <strong>neobmedzené miesta</strong> za $99/server/mo."
freeBody = "Naše licencovanie <strong>Open-Core</strong> umožňuje až <strong>{{freeTierLimit}}</strong> používateľov zadarmo na server. Na plynulé škálovanie odporúčame plán Stirling Server - <strong>neobmedzený počet používateľov</strong> a <strong>podporu SSO</strong> za $99/server/mes."
[onboarding.desktopInstall]
title = "Stiahnuť"
@@ -5177,7 +5177,7 @@ upgrade = "Nadgradi zdaj →"
freeTitle = "Licenca strežnika"
overLimitTitle = "Potrebna licenca strežnika"
overLimitBody = "Naše licenciranje brezplačno omogoča do <strong>{{freeTierLimit}}</strong> uporabnikov na strežnik. Imate <strong>{{overLimitUserCopy}}</strong> uporabnikov Stirling. Za nemoteno uporabo nadgradite na načrt Stirling Server – <strong>neomejena mesta</strong>, urejanje besedila PDF in popoln skrbniški nadzor za $99/strežnik/mesec."
freeBody = "Naše licenciranje <strong>Open-Core</strong> brezplačno omogoča do <strong>{{freeTierLimit}}</strong> uporabnikov na strežnik. Za nemoteno rast in zgodnji dostop do našega novega <strong>orodja za urejanje besedila PDF</strong> priporočamo načrt Stirling Server – polno urejanje in <strong>neomejena mesta</strong> za $99/strežnik/mesec."
freeBody = "Naše licenciranje <strong>Open-Core</strong> omogoča do <strong>{{freeTierLimit}}</strong> uporabnikov brezplačno na strežnik. Za nemoteno skaliranje priporočamo načrt Stirling Server - <strong>neomejena mesta</strong> in <strong>podpora za SSO</strong> za $99/strežnik/mesec."
[onboarding.desktopInstall]
title = "Prenesi"
@@ -1225,7 +1225,7 @@ odtExt = "OpenDocument tekst (.odt)"
pptExt = "PowerPoint (.pptx)"
odpExt = "OpenDocument prezentacija (.odp)"
txtExt = "Običan tekst (.txt)"
rtfExt = "Rich Text Format (.rtf)"
rtfExt = "Format obogaćenog teksta (.rtf)"
selectedFiles = "Izabrane datoteke"
noFileSelected = "Nije izabrana nijedna datoteka. Koristite panel datoteka da dodate datoteke."
convertFiles = "Konvertuj datoteke"
@@ -5177,7 +5177,7 @@ upgrade = "Nadogradite sada →"
freeTitle = "Serverska licenca"
overLimitTitle = "Potrebna serverska licenca"
overLimitBody = "Naše licenciranje dozvoljava do <strong>{{freeTierLimit}}</strong> korisnika besplatno po serveru. Imate <strong>{{overLimitUserCopy}}</strong> Stirling korisnika. Da nastavite bez prekida, pređite na Stirling Server plan - <strong>neograničena mesta</strong>, uređivanje PDF teksta i puna admin kontrola za $99/server/mes."
freeBody = "Naše <strong>Open-Core</strong> licenciranje dozvoljava do <strong>{{freeTierLimit}}</strong> korisnika besplatno po serveru. Da se bez prekida skalirate i dobijete rani pristup našem novom <strong>alatu za uređivanje PDF teksta</strong>, preporučujemo Stirling Server plan - puno uređivanje i <strong>neograničena mesta</strong> za $99/server/mes."
freeBody = "Naše licenciranje <strong>Open-Core</strong> dozvoljava do <strong>{{freeTierLimit}}</strong> korisnika besplatno po serveru. Za neometano skaliranje, preporučujemo plan Stirling Server - <strong>neograničena mesta</strong> i <strong>SSO podrška</strong> za $99/server/mo."
[onboarding.desktopInstall]
title = "Preuzmi"
@@ -5177,7 +5177,7 @@ upgrade = "Uppgradera nu →"
freeTitle = "Serverlicens"
overLimitTitle = "Serverlicens krävs"
overLimitBody = "Vår licensiering tillåter upp till <strong>{{freeTierLimit}}</strong> användare gratis per server. Du har <strong>{{overLimitUserCopy}}</strong> Stirling-användare. För att fortsätta utan avbrott, uppgradera till Stirling Server-planen - <strong>obegränsade platser</strong>, PDF-textredigering och full adminkontroll för $99/server/mån."
freeBody = "Vår <strong>Open-Core</strong>-licens tillåter upp till <strong>{{freeTierLimit}}</strong> användare gratis per server. För att skala utan avbrott och få tidig åtkomst till vårt nya <strong>PDF-textredigeringsverktyg</strong> rekommenderar vi Stirling Server-planen - full redigering och <strong>obegränsade platser</strong> för $99/server/mån."
freeBody = "Vår <strong>Open-Core</strong>-licens tillåter upp till <strong>{{freeTierLimit}}</strong> användare gratis per server. För att skala utan avbrott rekommenderar vi Stirling Server plan - <strong>obegränsat antal platser</strong> och <strong>SSO-stöd</strong> för $99/server/månad."
[onboarding.desktopInstall]
title = "Ladda ner"
@@ -1221,9 +1221,9 @@ pdfaDigitalSignatureWarning = "PDF มีลายเซ็นดิจิทั
fileFormat = "รูปแบบไฟล์"
wordDoc = "เอกสาร Word"
wordDocExt = "เอกสาร Word (.docx)"
odtExt = "OpenDocument Text (.odt)"
odtExt = "ข้อความ OpenDocument (.odt)"
pptExt = "PowerPoint (.pptx)"
odpExt = "OpenDocument Presentation (.odp)"
odpExt = "งานนำเสนอ OpenDocument (.odp)"
txtExt = "ข้อความล้วน (.txt)"
rtfExt = "Rich Text Format (.rtf)"
selectedFiles = "ไฟล์ที่เลือก"
@@ -5177,7 +5177,7 @@ upgrade = "อัปเกรดเลย →"
freeTitle = "ไลเซนส์เซิร์ฟเวอร์"
overLimitTitle = "ต้องใช้ไลเซนส์เซิร์ฟเวอร์"
overLimitBody = "สิทธิ์การใช้งานของเรารองรับผู้ใช้ได้ฟรีสูงสุด <strong>{{freeTierLimit}}</strong> คนต่อเซิร์ฟเวอร์ ขณะนี้คุณมีผู้ใช้ Stirling <strong>{{overLimitUserCopy}}</strong> คน เพื่อใช้งานต่อเนื่อง โปรดอัปเกรดเป็นแพ็กเกจ Stirling Server - <strong>ที่นั่งไม่จำกัด</strong> แก้ไขข้อความ PDF และควบคุมแอดมินเต็มรูปแบบ ราคา $99/ต่อเซิร์ฟเวอร์/เดือน"
freeBody = "ไลเซนส์แบบ <strong>Open-Core</strong> ของเรารองรับผู้ใช้ได้ฟรีสูงสุด <strong>{{freeTierLimit}}</strong> คนต่อเซิร์ฟเวอร์ เพื่อขยายการใช้งานได้ต่อเนื่องและเข้าถึง <strong>เครื่องมือแก้ไขข้อความ PDF</strong> ล่วงหน้า เราแนะนำแพ็กเกจ Stirling Server - แก้ไขได้เต็มรูปแบบและ <strong>ที่นั่งไม่จำกัด</strong> ราคา $99/ต่อเซิร์ฟเวอร์/เดือน"
freeBody = "สัญญาอนุญาตแบบ <strong>Open-Core</strong> ของเราอนุญาตให้ใช้งานฟรีได้สูงสุด <strong>{{freeTierLimit}}</strong> ผู้ใช้ต่อเซิร์ฟเวอร์หนึ่งเครื่อง เพื่อขยายการใช้งานอย่างต่อเนื่อง เราขอแนะนำแผน Stirling Server - <strong>ที่นั่งไม่จำกัด</strong> และ <strong>รองรับ SSO</strong> ในราคา $99/เซิร์ฟเวอร์/เดือน"
[onboarding.desktopInstall]
title = "ดาวน์โหลด"
@@ -5177,7 +5177,7 @@ upgrade = "Şimdi yükselt →"
freeTitle = "Sunucu Lisansı"
overLimitTitle = "Sunucu Lisansı Gerekli"
overLimitBody = "Lisansımız, sunucu başına ücretsiz olarak en fazla <strong>{{freeTierLimit}}</strong> kullanıcıya izin verir. <strong>{{overLimitUserCopy}}</strong> Stirling kullanıcınız var. Kesintisiz devam etmek için Stirling Server planına yükseltin - <strong>sınırsız koltuk</strong>, PDF metin düzenleme ve tam yönetici kontrolü $99/server/ay."
freeBody = "<strong>Open-Core</strong> lisansımız, sunucu başına ücretsiz olarak en fazla <strong>{{freeTierLimit}}</strong> kullanıcıya izin verir. Kesintisiz ölçeklemek ve yeni <strong>PDF metin düzenleme aracımıza</strong> erken erişim almak için Stirling Server planını öneririz - tam düzenleme ve <strong>sınırsız koltuk</strong> $99/server/ay."
freeBody = "<strong>Open-Core</strong> lisanslamamız, sunucu başına en fazla <strong>{{freeTierLimit}}</strong> kullanıcıya ücretsiz izin verir. Kesintisiz ölçeklendirme için Stirling Server planını öneririz - <strong>sınırsız kullanıcı</strong> ve <strong>SSO desteği</strong> için $99/sunucu/ay."
[onboarding.desktopInstall]
title = "İndir"
@@ -3790,7 +3790,7 @@ version = "Текущий релиз"
title = "Документація API"
header = "Документація API"
desc = "Переглядайте та тестуйте кінцеві точки API Stirling PDF"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,документація,swagger,кінцеві точки,розробка"
[cookieBanner.popUp]
title = "Як ми використовуємо файли cookie"
@@ -5177,7 +5177,7 @@ upgrade = "Оновити зараз →"
freeTitle = "Ліцензія сервера"
overLimitTitle = "Потрібна ліцензія сервера"
overLimitBody = "Наша ліцензія дозволяє до <strong>{{freeTierLimit}}</strong> користувачів безкоштовно на сервер. У вас <strong>{{overLimitUserCopy}}</strong> користувачів Stirling. Щоб працювати без перерв, перейдіть на план Stirling Server — <strong>необмежена кількість місць</strong>, редагування тексту PDF та повний адмін-контроль за $99/server/mo."
freeBody = "Наша <strong>Open-Core</strong> ліцензія дозволяє до <strong>{{freeTierLimit}}</strong> користувачів безкоштовно на сервер. Щоб масштабуватися безперервно та отримати ранній доступ до нового <strong>інструмента редагування тексту PDF</strong>, рекомендуємо план Stirling Server — повне редагування та <strong>необмежена кількість місць</strong> за $99/server/mo."
freeBody = "Наша ліцензія <strong>Open-Core</strong> дозволяє до <strong>{{freeTierLimit}}</strong> користувачів безкоштовно на сервер. Щоб масштабуватися без перерв, рекомендуємо план Stirling Server — <strong>необмежена кількість місць</strong> і <strong>підтримка SSO</strong> за $99/сервер/міс."
[onboarding.desktopInstall]
title = "Завантажити"
@@ -301,7 +301,7 @@ saveSettings = "Lưu cài đặt thao tác"
pipelineNamePrompt = "Nhập tên pipeline tại đây"
selectOperation = "Chọn thao tác"
addOperationButton = "Thêm thao tác"
pipelineHeader = "Pipeline:"
pipelineHeader = "Chuỗi xử lý:"
saveButton = "Tải xuống"
validateButton = "Xác thực"
@@ -5177,7 +5177,7 @@ upgrade = "Nâng cấp ngay →"
freeTitle = "Giấy phép Server"
overLimitTitle = "Cần giấy phép Server"
overLimitBody = "Giấy phép của chúng tôi cho phép tối đa <strong>{{freeTierLimit}}</strong> người dùng miễn phí mỗi server. Bạn có <strong>{{overLimitUserCopy}}</strong> người dùng Stirling. Để tiếp tục không gián đoạn, hãy nâng cấp lên gói Stirling Server - <strong>số ghế không giới hạn</strong>, chỉnh sửa văn bản PDF và toàn quyền quản trị với $99/server/tháng."
freeBody = "Giấy phép <strong>Open-Core</strong> của chúng tôi cho phép tối đa <strong>{{freeTierLimit}}</strong> người dùng miễn phí mỗi server. Để mở rộng không gián đoạn và truy cập sớm <strong>công cụ chỉnh sửa văn bản PDF</strong> mới, chúng tôi khuyến nghị gói Stirling Server - chỉnh sửa đầy đủ và <strong>số ghế không giới hạn</strong> với $99/server/tháng."
freeBody = "Giấy phép <strong>Open-Core</strong> của chúng tôi cho phép tối đa <strong>{{freeTierLimit}}</strong> người dùng miễn phí cho mỗi máy chủ. Để mở rộng quy mô liền mạch, chúng tôi khuyến nghị gói Stirling Server - <strong>số lượng người dùng không giới hạn</strong> và <strong>hỗ trợ SSO</strong> với giá $99/máy chủ/tháng."
[onboarding.desktopInstall]
title = "Tải xuống"
@@ -1181,7 +1181,7 @@ selectFilesPlaceholder = "在主视图中选择文件以开始"
settings = "设置"
conversionCompleted = "转换完成"
results = "结果"
defaultFilename = "converted_file"
defaultFilename = "已转换_文件"
conversionResults = "转换结果"
convertFrom = "从以下格式转换"
convertTo = "转换为"
@@ -5177,7 +5177,7 @@ upgrade = "立即升级 →"
freeTitle = "服务器许可证"
overLimitTitle = "需要服务器许可证"
overLimitBody = "我们的许可每台服务器最多允许 <strong>{{freeTierLimit}}</strong> 名用户免费使用。您共有 <strong>{{overLimitUserCopy}}</strong> 名 Stirling 用户。为避免中断,请升级到 Stirling Server 方案 - <strong>无限席位</strong>、PDF 文本编辑,以及每台服务器 $99/月 的完整管理员控制。"
freeBody = "我们的 <strong>开源内核(Open-Core)</strong> 许可允许每台服务器最多 <strong>{{freeTierLimit}}</strong> 名用户免费使用。为顺畅扩展并抢先体验全新的 <strong>PDF 文本编辑工具</strong>,我们推荐 Stirling Server 方案 - 完整编辑与 <strong>无限席位</strong>,$99/服务器/月。"
freeBody = "我们的<strong>Open-Core</strong>许可允许每台服务器最多<strong>{{freeTierLimit}}</strong>名用户免费使用。为实现不中断的扩展,我们推荐 Stirling Server 方案 - <strong>不限席位</strong>并提供<strong>SSO 支持</strong>,$99/服务器/月。"
[onboarding.desktopInstall]
title = "下载"
@@ -5177,7 +5177,7 @@ upgrade = "立即升级 →"
freeTitle = "服务器许可证"
overLimitTitle = "需要服务器许可证"
overLimitBody = "我们的许可允许每台服务器最多免费 <strong>{{freeTierLimit}}</strong> 名用户。您有 <strong>{{overLimitUserCopy}}</strong> 名 Stirling 用户。为不间断使用,请升级至 Stirling Server 方案 - <strong>无限席位</strong>、PDF 文本编辑,以及 $99/server/mo 的完整管理员控制。"
freeBody = "我们的 <strong>Open-Core</strong> 许可允许每台服务器最多免费 <strong>{{freeTierLimit}}</strong> 名用户。为无缝扩展并抢先体验全新的 <strong>PDF 文本编辑工具</strong>,推荐 Stirling Server 方案 - 完整编辑与 <strong>无限席位</strong>,$99/server/mo。"
freeBody = "我们的 <strong>Open-Core</strong> 许可允许每台服务器最多 <strong>{{freeTierLimit}}</strong> 名用户免费使用。为实现不间断扩展,我们推荐 Stirling Server 方案 - <strong>无限席位</strong> 和 <strong>SSO 支持</strong>,$99/server/mo."
[onboarding.desktopInstall]
title = "下载"
@@ -5177,7 +5177,7 @@ upgrade = "立即升級 →"
freeTitle = "伺服器授權"
overLimitTitle = "需要伺服器授權"
overLimitBody = "我們的授權允許每台伺服器最多 <strong>{{freeTierLimit}}</strong> 位使用者免費使用。你有 <strong>{{overLimitUserCopy}}</strong> 位 Stirling 使用者。若要不中斷使用,請升級至 Stirling Server 方案 - <strong>不限席次</strong>、PDF 文字編輯,以及完整管理控制,每台伺服器 $99/月。"
freeBody = "我們的 <strong>Open-Core</strong> 授權允許每台伺服器最多 <strong>{{freeTierLimit}}</strong> 位使用者免費使用。若要無縫擴充並搶先體驗全新的 <strong>PDF 文字編輯工具</strong>,建議升級至 Stirling Server 方案 - 完整編輯與 <strong>不限席次</strong>,每台伺服器 $99/月。"
freeBody = "我們的 <strong>Open-Core</strong> 授權允許每台伺服器最多 <strong>{{freeTierLimit}}</strong> 位使用者免費使用。若要無縫擴充,我們建議選用 Stirling Server 方案 - <strong>不限席次</strong> 與 <strong>SSO 支援</strong>,每伺服器每月 $99。"
[onboarding.desktopInstall]
title = "下載"
+18 -1
View File
@@ -2152,7 +2152,11 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"log",
"security-framework 2.11.1",
"security-framework 3.5.1",
"windows-sys 0.60.2",
"zeroize",
]
@@ -2378,7 +2382,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@@ -3841,6 +3845,19 @@ dependencies = [
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
+1 -1
View File
@@ -32,7 +32,7 @@ tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0"
tauri-plugin-opener = "2.0.0"
keyring = "3.6.1"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", features = ["json"] }
tiny_http = "0.12"
+36 -31
View File
@@ -1,4 +1,4 @@
use keyring::Entry;
use keyring::{Entry};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tauri::AppHandle;
@@ -21,53 +21,70 @@ pub struct UserInfo {
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY);
let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| {
log::error!("Failed to create keyring entry: {}", e);
format!("Failed to access keyring: {}", e)
})?;
log::debug!("Keyring entry created successfully");
Ok(entry)
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving auth token to keyring");
if token.is_empty() {
log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string());
}
let entry = get_keyring_entry()?;
entry
.set_password(&token)
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
.map_err(|e| {
log::error!("Failed to set password in keyring: {}", e);
format!("Failed to save token to keyring: {}", e)
})?;
// Verify the save worked
match entry.get_password() {
Ok(retrieved_token) => {
if retrieved_token != token {
log::error!("Token verification failed: Retrieved token doesn't match");
return Err("Token verification failed after save".to_string());
}
}
Err(e) => {
log::error!("Token verification failed: {}", e);
return Err(format!("Token verification failed: {}", e));
}
}
log::info!("Auth token saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
log::debug!("Retrieving auth token from keyring");
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
Err(e) => {
log::error!("Failed to retrieve token from keyring: {}", e);
Err(format!("Failed to retrieve token: {}", e))
},
}
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
@@ -78,8 +95,6 @@ pub async fn save_user_info(
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
@@ -96,7 +111,6 @@ pub async fn save_user_info(
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
@@ -117,8 +131,6 @@ pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, St
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
@@ -129,7 +141,6 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}
@@ -186,12 +197,8 @@ pub async fn login(
supabase_key: String,
saas_server_url: String,
) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
// Compare against the configured SaaS server URL
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
// Create HTTP client
let client = reqwest::Client::new();
@@ -248,8 +255,6 @@ pub async fn login(
.or_else(|| email.clone())
.unwrap_or_else(|| username);
log::info!("Supabase login successful for user: {}", username);
Ok(LoginResponse {
token: login_response.access_token,
username,
@@ -20,6 +20,7 @@ import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -81,6 +82,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
@@ -71,6 +71,20 @@ export default function Workbench() {
};
const renderMainContent = () => {
// Check for custom workbench views first
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
// PDF text editor handles its own empty state (shows dropzone when no document)
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
if (handlesOwnEmptyState || activeFiles.length > 0) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
}
// For base workbenches (or custom views that don't handle empty state), show landing page when no files
if (activeFiles.length === 0) {
return (
<LandingPage
@@ -143,15 +157,6 @@ export default function Workbench() {
);
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
return <LandingPage />;
}
};
@@ -195,7 +195,7 @@ export function useOnboardingOrchestrator(
accountService.getAccountData(),
accountService.getLoginPageData(),
]);
setRuntimeState((prev) => ({
...prev,
requiresPasswordChange: accountData.changeCredsFlag,
@@ -226,8 +226,12 @@ export function useOnboardingOrchestrator(
}), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => {
// If password change is required, ONLY show the first-login step
if (runtimeState.requiresPasswordChange) {
return ONBOARDING_STEPS.filter((step) => step.id === 'first-login');
}
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext]);
}, [conditionContext, runtimeState.requiresPasswordChange]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
@@ -238,23 +242,31 @@ export function useOnboardingOrchestrator(
useEffect(() => {
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
let firstUnseenIndex = -1;
for (let i = 0; i < activeFlow.length; i++) {
if (!hasSeenStep(activeFlow[i].id)) {
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
const isFirstLoginStep = activeFlow[i].id === 'first-login';
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
if (shouldTreatAsUnseen) {
firstUnseenIndex = i;
break;
}
}
if (firstUnseenIndex === -1) {
// Force reset index when password change is required (overrides initialIndexSet)
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
} else if (firstUnseenIndex === -1) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
} else if (!initialIndexSet.current) {
setCurrentStepIndex(firstUnseenIndex);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved]);
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
const totalSteps = activeFlow.length;
@@ -303,10 +315,13 @@ export function useOnboardingOrchestrator(
if (!currentStep || isLoading) {
return;
}
if (hasSeenStep(currentStep.id)) {
// Special case: never auto-complete first-login step if requiresPasswordChange is true
const isFirstLoginStep = currentStep.id === 'first-login';
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete]);
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
@@ -39,7 +39,7 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
components={{
strong: <strong />,
}}
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted and get early access to our new <strong>PDF text editing tool</strong>, we recommend the Stirling Server plan - full editing and <strong>unlimited seats</strong> for $99/server/mo."
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
/>
);
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
@@ -20,21 +21,36 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => {
const performNavigation = () => {
setActiveButton('tools');
// Preserve existing behavior used in QuickAccessBar header
handleReaderToggle();
handleBackToTools();
};
const handleClick = () => {
if (hasUnsavedChanges) {
navigationActions.requestNavigation(performNavigation);
return;
}
performNavigation();
};
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
navigationActions.requestNavigation(performNavigation);
return;
}
handleUnlessSpecialClick(e, handleClick);
};
@@ -14,8 +14,9 @@ export default function AppConfigLoader() {
useEffect(() => {
if (!loading && config) {
// Update supported languages if config specifies a language filter
updateSupportedLanguages(config.languages);
// Update supported languages and apply default locale from server config
// Priority: localStorage > config.defaultLocale > browser detection > fallback
updateSupportedLanguages(config.languages, config.defaultLocale);
}
}, [config, loading]);
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
import styles from '@app/components/shared/LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
// Types
interface LanguageSelectorProps {
@@ -163,13 +163,35 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
// Get the filtered list of supported languages from i18n
// This respects server config (ui.languages) applied by AppConfigLoader
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
.filter(lang => lang !== 'cimode'); // Exclude i18next debug language
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
.filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code))
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
label: `${name} (${code})`,
}));
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
if (languageOptions.length <= 1) {
return null;
}
// Calculate dropdown width and grid columns based on number of languages
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
const dropdownWidth = languageOptions.length <= 4 ? 300
: languageOptions.length <= 9 ? 400
: 600;
const gridColumns = languageOptions.length <= 4 ? 2
: languageOptions.length <= 9 ? 3
: 4;
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
// Create ripple effect at click position (only for button mode)
if (!compact) {
@@ -219,10 +241,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
<Menu
opened={opened}
onChange={setOpened}
width={600}
width={dropdownWidth}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
zIndex={Z_INDEX_CONFIG_MODAL}
withinPortal
transitionProps={{
transition: 'scale-y',
duration: 120,
@@ -278,21 +301,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
];
const isDisabled = !enabledLanguages.includes(option.value);
return (
<div
className={styles.languageGrid}
style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}
>
{languageOptions.map((option, index) => (
<LanguageItem
key={option.value}
option={option}
@@ -303,10 +318,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
rippleEffect={rippleEffect}
pendingLanguage={pendingLanguage}
compact={compact}
disabled={isDisabled}
disabled={false}
/>
);
})}
))}
</div>
</Menu.Dropdown>
</Menu>
@@ -12,7 +12,7 @@ interface NavigationWarningModalProps {
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const handleKeepWorking = () => {
@@ -41,7 +41,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
// Only show modal if there are unsaved changes AND there's an actual pending navigation
// This prevents the modal from showing due to spurious state updates
if (!hasUnsavedChanges || !pendingNavigation) {
return null;
}
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
@@ -32,6 +33,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
@@ -58,7 +61,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
};
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
@@ -67,6 +70,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
: null;
const handleClick = (e?: React.MouseEvent) => {
// If there are unsaved changes and this button should guard navigation, show warning modal
if (shouldGuardNavigation && hasUnsavedChanges) {
e?.preventDefault();
navigationActions.requestNavigation(() => {
config.onClick();
});
return;
}
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
@@ -89,7 +100,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick(),
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
})}
size={isActive ? 'lg' : 'md'}
@@ -222,7 +233,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Stack gap="lg" align="center">
{mainButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
{renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
</React.Fragment>
))}
</Stack>
@@ -2,23 +2,44 @@ import React from 'react';
import { Box, Group, Stack } from '@mantine/core';
interface SkeletonLoaderProps {
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer';
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer' | 'block';
count?: number;
animated?: boolean;
width?: number | string;
height?: number | string;
radius?: number | string;
}
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
type,
count = 8,
animated = true
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
type,
count = 8,
animated = true,
width,
height,
radius = 8,
}) => {
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
// Generic block skeleton for inline text/inputs/etc.
const renderBlock = () => (
<Box
w={typeof width === 'number' ? `${width}px` : width}
h={typeof height === 'number' ? `${height}px` : height}
bg="gray.1"
style={{
borderRadius: radius,
display: 'inline-block',
verticalAlign: 'middle',
...animationStyle
}}
/>
);
const renderPageGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '1rem'
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
@@ -26,7 +47,7 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
w="100%"
h={240}
bg="gray.1"
style={{
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
@@ -37,10 +58,10 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
);
const renderFileGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
@@ -48,7 +69,7 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
w="100%"
h={280}
bg="gray.1"
style={{
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
@@ -76,18 +97,20 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
{/* Main content skeleton */}
<Box
flex={1}
bg="gray.1"
style={{
<Box
flex={1}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle
}}
...animationStyle
}}
/>
</Stack>
);
switch (type) {
case 'block':
return renderBlock();
case 'pageGrid':
return renderPageGridSkeleton();
case 'fileGrid':
@@ -101,4 +124,4 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
}
};
export default SkeletonLoader;
export default SkeletonLoader;
@@ -97,6 +97,7 @@ export const OAUTH2_PROVIDERS: Provider[] = [
icon: 'key-rounded',
type: 'oauth2',
scope: 'SSO',
businessTier: false, // Server tier - OAuth2/OIDC SSO
fields: [
{
key: 'issuer',
@@ -141,6 +142,7 @@ export const GENERIC_OAUTH2_PROVIDER: Provider = {
icon: 'link-rounded',
type: 'oauth2',
scope: 'SSO',
businessTier: false, // Server tier - OAuth2/OIDC SSO
fields: [
{
key: 'enabled',
@@ -262,8 +264,8 @@ export const SAML2_PROVIDER: Provider = {
name: 'SAML2',
icon: 'verified-user-rounded',
type: 'saml2',
scope: 'SSO',
businessTier: true,
scope: 'SSO (SAML)',
businessTier: true, // Enterprise tier - SAML only
fields: [
{
key: 'enabled',
@@ -16,6 +16,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import FitText from '@app/components/shared/FitText';
@@ -31,6 +32,8 @@ const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = 'right' }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
@@ -150,10 +153,16 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
const performNavigation = () => {
setActiveButton('tools');
handleBackToTools();
});
};
if (hasUnsavedChanges) {
e.preventDefault();
navigationActions.requestNavigation(performNavigation);
return;
}
handleUnlessSpecialClick(e, performNavigation);
}}
size={'lg'}
variant="subtle"
@@ -0,0 +1,128 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { Badge, Divider, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type {
PdfInfoReportData,
PdfInfoReportEntry,
PdfInfoBackendData,
ParsedPdfSections,
} from '@app/types/getPdfInfo';
import '@app/components/tools/validateSignature/reportView/styles.css';
import SummarySection from '@app/components/tools/getPdfInfo/sections/SummarySection';
import KeyValueSection from '@app/components/tools/getPdfInfo/sections/KeyValueSection';
import TableOfContentsSection from '@app/components/tools/getPdfInfo/sections/TableOfContentsSection';
import OtherSection from '@app/components/tools/getPdfInfo/sections/OtherSection';
import PerPageSection from '@app/components/tools/getPdfInfo/sections/PerPageSection';
/** Valid section anchor IDs for navigation */
const VALID_ANCHORS = new Set([
'summary', 'metadata', 'formFields', 'basicInfo', 'documentInfo',
'compliance', 'encryption', 'permissions', 'toc', 'other', 'perPage',
]);
interface GetPdfInfoReportViewProps {
data: PdfInfoReportData & { scrollTo?: string | null };
}
const GetPdfInfoReportView: React.FC<GetPdfInfoReportViewProps> = ({ data }) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const entry: PdfInfoReportEntry | null = data.entries[0] ?? null;
useEffect(() => {
if (!data.scrollTo || !VALID_ANCHORS.has(data.scrollTo)) return;
const anchor = data.scrollTo;
const container = containerRef.current;
const el = container?.querySelector<HTMLElement>(`#${anchor}`);
if (el && container) {
// Calculate scroll position with 4rem buffer from top
const bufferPx = parseFloat(getComputedStyle(document.documentElement).fontSize) * 4;
const elementTop = el.getBoundingClientRect().top;
const containerTop = container.getBoundingClientRect().top;
const currentScroll = container.scrollTop;
const targetScroll = currentScroll + (elementTop - containerTop) - bufferPx;
container.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' });
// Flash highlight the section
el.classList.remove('section-flash-highlight');
void el.offsetWidth; // Force reflow
el.classList.add('section-flash-highlight');
setTimeout(() => el.classList.remove('section-flash-highlight'), 1500);
}
}, [data.scrollTo]);
const sections = useMemo((): ParsedPdfSections => {
const raw: PdfInfoBackendData = entry?.data ?? {};
return {
metadata: raw.Metadata ?? null,
formFields: raw.FormFields ?? raw['Form Fields'] ?? null,
basicInfo: raw.BasicInfo ?? raw['Basic Info'] ?? null,
documentInfo: raw.DocumentInfo ?? raw['Document Info'] ?? null,
compliance: raw.Compliancy ?? raw.Compliance ?? null,
encryption: raw.Encryption ?? null,
permissions: raw.Permissions ?? null,
toc: raw['Bookmarks/Outline/TOC'] ?? raw['Table of Contents'] ?? null,
other: raw.Other ?? null,
perPage: raw.PerPageInfo ?? raw['Per Page Info'] ?? null,
summaryData: raw.SummaryData ?? null,
};
}, [entry]);
if (!entry) {
return (
<div className="report-container">
<Stack gap="md" align="center">
<Badge color="gray" variant="light">No Data</Badge>
<Text size="sm" c="dimmed">Run the tool to generate the report.</Text>
</Stack>
</div>
);
}
return (
<div className="report-container" ref={containerRef}>
<Stack gap="xl" align="center">
<div className="simulated-page">
<Stack gap="lg">
<Stack gap="xs">
<Text fw={700} size="xl" style={{ lineHeight: 1.3, wordBreak: 'break-word' }}>
{entry.fileName}
<Text component="span" fw={700}> - {t('getPdfInfo.summary.title', 'PDF Summary')}</Text>
</Text>
<Divider />
</Stack>
<SummarySection sections={sections} hideSectionTitle />
<KeyValueSection title={t('getPdfInfo.sections.metadata', 'Metadata')} anchorId="metadata" obj={sections.metadata} />
<KeyValueSection title={t('getPdfInfo.sections.formFields', 'Form Fields')} anchorId="formFields" obj={sections.formFields} />
<KeyValueSection title={t('getPdfInfo.sections.basicInfo', 'Basic Info')} anchorId="basicInfo" obj={sections.basicInfo} />
<KeyValueSection title={t('getPdfInfo.sections.documentInfo', 'Document Info')} anchorId="documentInfo" obj={sections.documentInfo} />
<KeyValueSection title={t('getPdfInfo.sections.compliance', 'Compliance')} anchorId="compliance" obj={sections.compliance} />
<KeyValueSection title={t('getPdfInfo.sections.encryption', 'Encryption')} anchorId="encryption" obj={sections.encryption} />
<KeyValueSection title={t('getPdfInfo.sections.permissions', 'Permissions')} anchorId="permissions" obj={sections.permissions} />
<TableOfContentsSection anchorId="toc" tocArray={sections.toc ?? []} />
<OtherSection anchorId="other" other={sections.other} />
<PerPageSection anchorId="perPage" perPage={sections.perPage} />
</Stack>
</div>
</Stack>
</div>
);
};
export default GetPdfInfoReportView;
@@ -0,0 +1,79 @@
import { useCallback, useMemo } from 'react';
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
interface GetPdfInfoResultsProps {
operation: GetPdfInfoOperationHook;
isLoading: boolean;
errorMessage: string | null;
}
const findFileByExtension = (files: File[], extension: string) => {
return files.find((file) => file.name.toLowerCase().endsWith(extension));
};
const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoResultsProps) => {
const { t } = useTranslation();
const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
const selectedFile = useMemo(() => jsonFile ?? null, [jsonFile]);
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}, []);
if (isLoading && operation.results.length === 0) {
return (
<Group justify="center" gap="sm" py="md">
<Loader size="sm" />
<Text>{t('getPdfInfo.processing', 'Extracting information...')}</Text>
</Group>
);
}
if (!isLoading && operation.results.length === 0) {
return (
<Alert color="gray" variant="light" title={t('getPdfInfo.results', 'Results')}>
<Text size="sm">{t('getPdfInfo.noResults', 'Run the tool to generate a report.')}</Text>
</Alert>
);
}
return (
<Stack gap="md">
{/* No background post-processing once JSON is ready */}
{errorMessage && (
<Alert color="yellow" variant="light">
<Text size="sm">{errorMessage}</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('getPdfInfo.downloads', 'Downloads')}
</Text>
<Button
color="blue"
onClick={() => selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
fullWidth
>
{selectedDownloadLabel}
</Button>
</Stack>
</Stack>
);
};
export default GetPdfInfoResults;
@@ -0,0 +1,22 @@
import React from 'react';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
interface KeyValueSectionProps {
title: string;
anchorId: string;
obj?: Record<string, unknown> | null;
emptyLabel?: string;
}
const KeyValueSection: React.FC<KeyValueSectionProps> = ({ title, anchorId, obj, emptyLabel }) => {
return (
<SectionBlock title={title} anchorId={anchorId}>
<KeyValueList obj={obj} emptyLabel={emptyLabel} />
</SectionBlock>
);
};
export default KeyValueSection;
@@ -0,0 +1,84 @@
import React from 'react';
import { Accordion, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { PdfOtherInfo } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import ScrollableCodeBlock from '@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock';
import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles';
interface OtherSectionProps {
anchorId: string;
other?: PdfOtherInfo | null;
}
const renderList = (arr: unknown[] | undefined, emptyText: string) => {
if (!arr || arr.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{arr.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
);
};
const OtherSection: React.FC<OtherSectionProps> = ({ anchorId, other }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
const structureTreeContent = Array.isArray(other?.StructureTree) && other.StructureTree.length > 0
? JSON.stringify(other.StructureTree, null, 2)
: null;
return (
<SectionBlock title={t('getPdfInfo.sections.other', 'Other')} anchorId={anchorId}>
<Stack gap="sm">
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.attachments', 'Attachments')}</Text>
{renderList(other?.Attachments, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.embeddedFiles', 'Embedded Files')}</Text>
{renderList(other?.EmbeddedFiles, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.javaScript', 'JavaScript')}</Text>
{renderList(other?.JavaScript, noneDetected)}
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.other.layers', 'Layers')}</Text>
{renderList(other?.Layers, noneDetected)}
</Stack>
<Accordion
variant="separated"
radius="md"
defaultValue=""
styles={pdfInfoAccordionStyles}
>
<Accordion.Item value="structureTree">
<Accordion.Control>
<Text fw={600} size="sm">{t('getPdfInfo.other.structureTree', 'StructureTree')}</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollableCodeBlock content={structureTreeContent} maxHeight="20rem" />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="xmp">
<Accordion.Control>
<Text fw={600} size="sm">{t('getPdfInfo.other.xmp', 'XMPMetadata')}</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollableCodeBlock content={other?.XMPMetadata} maxHeight="400px" />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</SectionBlock>
);
};
export default OtherSection;
@@ -0,0 +1,122 @@
import React from 'react';
import { Accordion, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { PdfPerPageInfo, PdfPageInfo, PdfFontInfo } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles';
interface PerPageSectionProps {
anchorId: string;
perPage?: PdfPerPageInfo | null;
}
const renderList = (arr: unknown[] | undefined, emptyText: string) => {
if (!arr || arr.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{arr.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
);
};
const renderFontsList = (fonts: PdfFontInfo[] | undefined, emptyText: string) => {
if (!fonts || fonts.length === 0) return <Text size="sm" c="dimmed">{emptyText}</Text>;
return (
<Stack gap={4}>
{fonts.map((font, idx) => (
<Text key={idx} size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}>
{`${font.Name ?? 'Unknown'}${font.IsEmbedded ? ' (embedded)' : ''}`}
</Text>
))}
</Stack>
);
};
const PerPageSection: React.FC<PerPageSectionProps> = ({ anchorId, perPage }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
const hasPages = perPage && Object.keys(perPage).length > 0;
return (
<SectionBlock title={t('getPdfInfo.sections.perPageInfo', 'Per Page Info')} anchorId={anchorId}>
{hasPages ? (
<Accordion
variant="separated"
radius="md"
defaultValue=""
styles={pdfInfoAccordionStyles}
>
{Object.entries(perPage).map(([pageLabel, pageInfo]: [string, PdfPageInfo]) => (
<Accordion.Item key={pageLabel} value={pageLabel}>
<Accordion.Control>
<Text fw={600} size="sm">{pageLabel}</Text>
</Accordion.Control>
<Accordion.Panel>
<div style={{ backgroundColor: 'var(--bg-raised)', color: 'var(--text-primary)', borderRadius: 8, padding: 12 }}>
<Stack gap="sm">
{pageInfo?.Size && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.size', 'Size')}</Text>
<KeyValueList obj={pageInfo.Size} />
</Stack>
)}
<KeyValueList obj={{
'Rotation': pageInfo?.Rotation,
'Page Orientation': pageInfo?.['Page Orientation'],
'MediaBox': pageInfo?.MediaBox,
'CropBox': pageInfo?.CropBox,
'BleedBox': pageInfo?.BleedBox,
'TrimBox': pageInfo?.TrimBox,
'ArtBox': pageInfo?.ArtBox,
'Text Characters Count': pageInfo?.['Text Characters Count'],
}} />
{pageInfo?.Annotations && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.annotations', 'Annotations')}</Text>
<KeyValueList obj={pageInfo.Annotations} />
</Stack>
)}
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.images', 'Images')}</Text>
{renderList(pageInfo?.Images, noneDetected)}
</Stack>
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.links', 'Links')}</Text>
{renderList(pageInfo?.Links, noneDetected)}
</Stack>
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.fonts', 'Fonts')}</Text>
{renderFontsList(pageInfo?.Fonts, noneDetected)}
</Stack>
{pageInfo?.XObjectCounts && (
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.xobjects', 'XObject Counts')}</Text>
<KeyValueList obj={pageInfo.XObjectCounts} />
</Stack>
)}
<Stack gap={4}>
<Text fw={600} size="sm">{t('getPdfInfo.perPage.multimedia', 'Multimedia')}</Text>
{renderList(pageInfo?.Multimedia, noneDetected)}
</Stack>
</Stack>
</div>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
) : (
<Text size="sm" c="dimmed">{noneDetected}</Text>
)}
</SectionBlock>
);
};
export default PerPageSection;
@@ -0,0 +1,148 @@
import React, { useMemo } from 'react';
import { Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { ParsedPdfSections, PdfFontInfo } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList';
interface SummarySectionProps {
sections: ParsedPdfSections;
hideSectionTitle?: boolean;
}
const SummarySection: React.FC<SummarySectionProps> = ({ sections, hideSectionTitle = false }) => {
const { t } = useTranslation();
const summaryBlocks = useMemo(() => {
const basic = sections.basicInfo ?? {};
const docInfo = sections.documentInfo ?? {};
const metadata = sections.metadata ?? {};
const encryption = sections.encryption ?? {};
const permissions = sections.permissions ?? {};
const summary = sections.summaryData ?? {};
const other = sections.other ?? {};
const perPage = sections.perPage ?? {};
const pages = basic['Number of pages'];
const fileSizeBytes = basic.FileSizeInBytes;
const pdfVersion = docInfo['PDF version'];
const language = basic.Language;
const basicInformation: Record<string, unknown> = {
[t('getPdfInfo.summary.pages', 'Pages')]: pages,
[t('getPdfInfo.summary.fileSize', 'File Size')]: typeof fileSizeBytes === 'number' ? `${(fileSizeBytes / 1024).toFixed(2)} KB` : fileSizeBytes,
[t('getPdfInfo.summary.pdfVersion', 'PDF Version')]: pdfVersion,
[t('getPdfInfo.summary.language', 'Language')]: language,
};
const documentInformation: Record<string, unknown> = {
[t('getPdfInfo.summary.title', 'Title')]: metadata.Title,
[t('getPdfInfo.summary.author', 'Author')]: metadata.Author,
[t('getPdfInfo.summary.created', 'Created')]: metadata.CreationDate,
[t('getPdfInfo.summary.modified', 'Modified')]: metadata.ModificationDate,
};
const securityStatusText = encryption.IsEncrypted
? t('getPdfInfo.summary.security.encrypted', 'Encrypted PDF - Password protection present')
: t('getPdfInfo.summary.security.unencrypted', 'Unencrypted PDF - No password protection');
const restrictedCount = summary.restrictedPermissionsCount ?? 0;
const permissionsAllAllowed = Object.values(permissions).every((v) => v === 'Allowed');
const permSummary = permissionsAllAllowed
? t('getPdfInfo.summary.permsAll', 'All Permissions Allowed')
: restrictedCount > 0
? t('getPdfInfo.summary.permsRestricted', '{{count}} restrictions', { count: restrictedCount })
: t('getPdfInfo.summary.permsMixed', 'Some permissions restricted');
const complianceText = sections.compliance && Object.values(sections.compliance).some(Boolean)
? t('getPdfInfo.summary.hasCompliance', 'Has compliance standards')
: t('getPdfInfo.summary.noCompliance', 'No Compliance Standards');
// Helper to get first page data
const firstPage = perPage['Page 1'];
const firstPageFonts: PdfFontInfo[] = firstPage?.Fonts ?? [];
const technical: Record<string, unknown> = {
[t('getPdfInfo.summary.tech.images', 'Images')]: (() => {
const total = basic.TotalImages;
if (typeof total === 'number') return total === 0 ? 'None' : `${total}`;
return 'None';
})(),
[t('getPdfInfo.summary.tech.fonts', 'Fonts')]: (() => {
if (firstPageFonts.length === 0) return 'None';
const embedded = firstPageFonts.filter((f) => f.IsEmbedded).length;
return `${firstPageFonts.length} (${embedded} embedded)`;
})(),
[t('getPdfInfo.summary.tech.formFields', 'Form Fields')]: sections.formFields && Object.keys(sections.formFields).length > 0 ? Object.keys(sections.formFields).length : 'None',
[t('getPdfInfo.summary.tech.embeddedFiles', 'Embedded Files')]: other.EmbeddedFiles?.length ?? 'None',
[t('getPdfInfo.summary.tech.javaScript', 'JavaScript')]: other.JavaScript?.length ?? 'None',
[t('getPdfInfo.summary.tech.layers', 'Layers')]: other.Layers?.length ?? 'None',
[t('getPdfInfo.summary.tech.bookmarks', 'Bookmarks')]: sections.toc?.length ?? 'None',
[t('getPdfInfo.summary.tech.multimedia', 'Multimedia')]: firstPage?.Multimedia?.length ?? 'None',
};
const overview = (() => {
const tTitle = metadata.Title ? `"${metadata.Title}"` : t('getPdfInfo.summary.overview.untitled', 'an untitled document');
const author = metadata.Author || t('getPdfInfo.summary.overview.unknown', 'Unknown Author');
const pagesCount = typeof pages === 'number' ? pages : '?';
const version = pdfVersion ?? '?';
return t('getPdfInfo.summary.overview.text', 'This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}}).', {
pages: pagesCount,
title: tTitle,
author,
version,
});
})();
return {
basicInformation,
documentInformation,
securityStatusText,
permSummary,
complianceText,
technical,
overview,
};
}, [sections, t]);
const content = (
<Stack gap="md">
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.basic', 'Basic Information')}</Text>
<KeyValueList obj={summaryBlocks.basicInformation} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.documentInfo', 'Document Information')}</Text>
<KeyValueList obj={summaryBlocks.documentInformation} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.securityTitle', 'Security Status')}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.securityStatusText}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.permSummary}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.complianceText}</Text>
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.technical', 'Technical')}</Text>
<KeyValueList obj={summaryBlocks.technical} />
</Stack>
<Stack gap={6}>
<Text fw={600} size="sm">{t('getPdfInfo.summary.overviewTitle', 'PDF Overview')}</Text>
<Text size="sm" c="dimmed">{summaryBlocks.overview}</Text>
</Stack>
</Stack>
);
if (hideSectionTitle) {
return <div id="summary">{content}</div>;
}
return (
<SectionBlock title={t('getPdfInfo.summary.title', 'PDF Summary')} anchorId="summary">
{content}
</SectionBlock>
);
};
export default SummarySection;
@@ -0,0 +1,35 @@
import React from 'react';
import { Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { PdfTocEntry } from '@app/types/getPdfInfo';
import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock';
interface TableOfContentsSectionProps {
anchorId: string;
tocArray: PdfTocEntry[];
}
const TableOfContentsSection: React.FC<TableOfContentsSectionProps> = ({ anchorId, tocArray }) => {
const { t } = useTranslation();
const noneDetected = t('getPdfInfo.noneDetected', 'None detected');
return (
<SectionBlock title={t('getPdfInfo.sections.tableOfContents', 'Table of Contents')} anchorId={anchorId}>
{!tocArray || tocArray.length === 0 ? (
<Text size="sm" c="dimmed">{noneDetected}</Text>
) : (
<Stack gap={4}>
{tocArray.map((item, idx) => (
<Text key={idx} size="sm" c="dimmed">
{typeof item === 'string' ? item : JSON.stringify(item)}
</Text>
))}
</Stack>
)}
</SectionBlock>
);
};
export default TableOfContentsSection;
@@ -0,0 +1,29 @@
import React from 'react';
import { Group, Stack, Text } from '@mantine/core';
interface KeyValueListProps {
obj?: Record<string, unknown> | null;
emptyLabel?: string;
}
const KeyValueList: React.FC<KeyValueListProps> = ({ obj, emptyLabel }) => {
if (!obj || Object.keys(obj).length === 0) {
return <Text size="sm" c="dimmed">{emptyLabel ?? 'None detected'}</Text>;
}
return (
<Stack gap={6}>
{Object.entries(obj).map(([k, v]) => (
<Group key={k} wrap="nowrap" align="flex-start" style={{ width: '100%' }}>
<Text size="sm" style={{ minWidth: 180, maxWidth: 180, flexShrink: 0 }}>{k}</Text>
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', overflowWrap: 'break-word', flex: 1 }}>
{v == null ? '' : String(v)}
</Text>
</Group>
))}
</Stack>
);
};
export default KeyValueList;
@@ -0,0 +1,47 @@
import React from 'react';
import { Code, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ScrollableCodeBlockProps {
content: string | null | undefined;
maxHeight?: string;
emptyMessage?: string;
}
/**
* A reusable scrollable code block component with consistent styling.
* Used for displaying large text content like XMP metadata or structure trees.
*/
const ScrollableCodeBlock: React.FC<ScrollableCodeBlockProps> = ({
content,
maxHeight = '400px',
emptyMessage,
}) => {
const { t } = useTranslation();
if (!content) {
return (
<Text size="sm" c="dimmed">
{emptyMessage ?? t('getPdfInfo.noneDetected', 'None detected')}
</Text>
);
}
return (
<Code
block
style={{
whiteSpace: 'pre-wrap',
backgroundColor: 'var(--bg-raised)',
color: 'var(--text-primary)',
maxHeight,
overflowY: 'auto',
}}
>
{content}
</Code>
);
};
export default ScrollableCodeBlock;
@@ -0,0 +1,22 @@
import React from 'react';
import { Stack, Text, Divider } from '@mantine/core';
interface SectionBlockProps {
title: string;
anchorId: string;
children: React.ReactNode;
}
const SectionBlock: React.FC<SectionBlockProps> = ({ title, anchorId, children }) => {
return (
<Stack gap="sm" id={anchorId}>
<Text fw={700} size="lg">{title}</Text>
<Divider />
{children}
</Stack>
);
};
export default SectionBlock;
@@ -0,0 +1,14 @@
import type { AccordionStylesNames } from '@mantine/core';
import type { CSSProperties } from 'react';
type AccordionStyles = Partial<Record<AccordionStylesNames, CSSProperties>>;
export const pdfInfoAccordionStyles: AccordionStyles = {
item: {
backgroundColor: 'var(--accordion-item-bg)',
},
control: {
backgroundColor: 'transparent',
},
};
@@ -134,7 +134,7 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
textDecoration: 'underline',
textAlign: 'center'
}}
onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')}
onClick={() => window.open('https://docs.stirlingpdf.com/Configuration/OCR', '_blank')}
>
{t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')}
</Text>
@@ -158,4 +158,4 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
);
};
export default LanguagePicker;
export default LanguagePicker;
@@ -21,6 +21,7 @@ import {
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined';
@@ -32,9 +33,12 @@ import CloseIcon from '@mui/icons-material/Close';
import MergeTypeIcon from '@mui/icons-material/MergeType';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import UploadFileIcon from '@mui/icons-material/UploadFileOutlined';
import SaveIcon from '@mui/icons-material/SaveOutlined';
import { Rnd } from 'react-rnd';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { useFileContext } from '@app/contexts/FileContext';
import {
PdfTextEditorViewData,
PdfJsonFont,
@@ -313,6 +317,7 @@ type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const { t } = useTranslation();
const { activeFiles } = useFileContext();
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [activeImageId, setActiveImageId] = useState<string | null>(null);
@@ -329,6 +334,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const caretOffsetsRef = useRef<Map<string, number>>(new Map());
const composingGroupsRef = useRef<Set<string>>(new Set());
const lastSelectedGroupIdRef = useRef<string | null>(null);
const widthOverridesRef = useRef<Map<string, number>>(widthOverrides);
const resizingRef = useRef<{
@@ -375,6 +381,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
fileName,
errorMessage,
isGeneratingPdf,
isSavingToWorkbench,
isConverting,
conversionProgress,
hasChanges,
@@ -389,11 +396,12 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
onReset,
onDownloadJson,
onGeneratePdf,
onGeneratePdfForNavigation,
onSaveToWorkbench,
onForceSingleTextElementChange,
onGroupingModeChange,
onMergeGroups,
onUngroupGroup,
onLoadFile,
} = data;
// Define derived variables immediately after props destructuring, before any hooks
@@ -607,6 +615,18 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
[editingGroupId, onGroupEdit],
);
const handleCompositionStart = useCallback((groupId: string) => {
composingGroupsRef.current.add(groupId);
}, []);
const handleCompositionEnd = useCallback(
(element: HTMLElement, pageIndex: number, groupId: string) => {
composingGroupsRef.current.delete(groupId);
syncEditorValue(element, pageIndex, groupId);
},
[syncEditorValue],
);
const handleMergeSelection = useCallback(() => {
if (!canMergeSelection) {
return;
@@ -1430,7 +1450,8 @@ const selectionToolbarPosition = useMemo(() => {
height: '100%',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
alignItems: 'start',
gridTemplateRows: '1fr',
alignItems: hasDocument ? 'start' : 'stretch',
gap: '1.5rem',
}}
>
@@ -1486,6 +1507,17 @@ const selectionToolbarPosition = useMemo(() => {
>
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
</Button>
<Button
variant="filled"
color="green"
leftSection={<SaveIcon fontSize="small" />}
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
</Button>
</Stack>
{fileName && (
@@ -1639,17 +1671,45 @@ const selectionToolbarPosition = useMemo(() => {
)}
{!hasDocument && !isConverting && (
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
<Stack align="center" gap="md">
<DescriptionIcon sx={{ fontSize: 48 }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')}
</Text>
</Stack>
</Card>
<Stack
align="center"
justify="center"
style={{ gridColumn: '1 / 2', gridRow: 1, height: '100%' }}
>
<Dropzone
onDrop={(files) => {
if (files.length > 0) {
onLoadFile(files[0]);
}
}}
accept={['application/pdf', 'application/json']}
maxFiles={1}
style={{
width: '100%',
maxWidth: 480,
minHeight: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--mantine-color-gray-4)',
borderRadius: 'var(--mantine-radius-lg)',
cursor: 'pointer',
transition: 'border-color 150ms ease, background-color 150ms ease',
}}
>
<Stack align="center" gap="md" style={{ pointerEvents: 'none' }}>
<UploadFileIcon sx={{ fontSize: 48, color: 'var(--mantine-color-blue-5)' }} />
<Text size="lg" fw={600}>
{t('pdfTextEditor.empty.title', 'No document loaded')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
{activeFiles.length > 0
? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse')
: t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF or JSON file here, or click to browse')}
</Text>
</Stack>
</Dropzone>
</Stack>
)}
{isConverting && (
@@ -1683,7 +1743,7 @@ const selectionToolbarPosition = useMemo(() => {
</Card>
)}
{hasDocument && (
{hasDocument && !isConverting && (
<Stack
gap="lg"
className="flex-1"
@@ -2232,6 +2292,10 @@ const selectionToolbarPosition = useMemo(() => {
contentEditable
suppressContentEditableWarning
data-editor-group={group.id}
onCompositionStart={() => handleCompositionStart(group.id)}
onCompositionEnd={(event) =>
handleCompositionEnd(event.currentTarget, group.pageIndex, group.id)
}
onFocus={(event) => {
const primaryFont = fontFamily.split(',')[0]?.replace(/['"]/g, '').trim();
if (primaryFont && typeof document !== 'undefined') {
@@ -2253,6 +2317,7 @@ const selectionToolbarPosition = useMemo(() => {
event.stopPropagation();
}}
onBlur={(event) => {
composingGroupsRef.current.delete(group.id);
syncEditorValue(event.currentTarget, group.pageIndex, group.id, {
skipCaretRestore: true,
});
@@ -2262,6 +2327,9 @@ const selectionToolbarPosition = useMemo(() => {
setEditingGroupId(null);
}}
onInput={(event) => {
if (composingGroupsRef.current.has(group.id)) {
return;
}
syncEditorValue(event.currentTarget, group.pageIndex, group.id);
}}
style={{
@@ -2444,7 +2512,7 @@ const selectionToolbarPosition = useMemo(() => {
{/* Navigation Warning Modal */}
<NavigationWarningModal
onApplyAndContinue={onGeneratePdfForNavigation}
onApplyAndContinue={onSaveToWorkbench}
/>
</Stack>
);
@@ -87,6 +87,7 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
{config.steps.map((stepConfig) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
onCollapsedClick: stepConfig.onCollapsedClick,
tooltip: stepConfig.tooltip
}, stepConfig.content)

Some files were not shown because too many files have changed in this diff Show More