Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59d2595a6 | ||
|
|
5c4be090aa | ||
|
|
21657a3fa6 | ||
|
|
bc11875081 | ||
|
|
31b3219169 | ||
|
|
d8a99fcb07 | ||
|
|
8016d271aa | ||
|
|
8f1bef7f46 | ||
|
|
e6db57e031 | ||
|
|
daf749e6be | ||
|
|
53d167eda5 | ||
|
|
d74856f675 | ||
|
|
f8386843d4 | ||
|
|
2277a94c91 | ||
|
|
3b8b539efc | ||
|
|
7253b9fa6d | ||
|
|
9a1f89486d | ||
|
|
a6614e1bfb | ||
|
|
ae5b1a4b02 | ||
|
|
9fcaace8ed | ||
|
|
2ab7945130 | ||
|
|
a8db2fda18 | ||
|
|
80f2980755 | ||
|
|
2d8b0ff08c | ||
|
|
991e03970e | ||
|
|
64d343b765 | ||
|
|
2534c532b7 | ||
|
|
8757a80fda |
@@ -92,8 +92,6 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
@@ -180,7 +178,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -190,7 +188,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -225,7 +223,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -314,7 +312,8 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
@@ -323,7 +322,7 @@ jobs:
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
@@ -514,7 +513,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest'
|
||||
if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying Windows code signatures..."
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -25,6 +25,7 @@ public class ExternalAppDepConfig {
|
||||
private final String weasyprintPath;
|
||||
private final String unoconvPath;
|
||||
private final Map<String, List<String>> commandToGroupMapping;
|
||||
private volatile boolean dependenciesChecked = false;
|
||||
|
||||
public ExternalAppDepConfig(
|
||||
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
|
||||
@@ -111,6 +112,10 @@ public class ExternalAppDepConfig {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isDependenciesChecked() {
|
||||
return dependenciesChecked;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void checkDependencies() {
|
||||
// Check core dependencies
|
||||
@@ -162,5 +167,7 @@ public class ExternalAppDepConfig {
|
||||
}
|
||||
}
|
||||
endpointConfiguration.logDisabledEndpointsSummary();
|
||||
dependenciesChecked = true;
|
||||
log.info("Dependency checks completed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
|
||||
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
.allowedOriginPatterns(
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
|
||||
@@ -35,6 +35,7 @@ public class ConfigController {
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final UserServiceInterface userService;
|
||||
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -43,12 +44,14 @@ public class ConfigController {
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
UserServiceInterface userService) {
|
||||
UserServiceInterface userService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.userService = userService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
@@ -56,6 +59,9 @@ public class ConfigController {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
|
||||
try {
|
||||
// Add dependency check status
|
||||
configData.put("dependenciesReady", externalAppDepConfig.isDependenciesChecked());
|
||||
|
||||
// Get AppConfig bean
|
||||
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
#############################################################################################################
|
||||
|
||||
security:
|
||||
enableLogin: false # set to 'true' to enable login
|
||||
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
|
||||
|
||||
@@ -120,6 +120,37 @@ public class ProprietaryUIDataController {
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.getEnableLogin());
|
||||
|
||||
// Check if this is first-time setup with default credentials
|
||||
// The isFirstLogin flag captures: default username/password usage and unchanged state
|
||||
boolean isFirstTimeSetup = false;
|
||||
boolean showDefaultCredentials = false;
|
||||
|
||||
List<User> allUsers = userRepository.findAll();
|
||||
List<User> realUsers =
|
||||
allUsers.stream()
|
||||
.filter(
|
||||
user ->
|
||||
!Role.INTERNAL_API_USER
|
||||
.getRoleId()
|
||||
.equals(user.getUsername()))
|
||||
.toList();
|
||||
long userCount = realUsers.size();
|
||||
|
||||
if (userCount == 0) {
|
||||
isFirstTimeSetup = true;
|
||||
showDefaultCredentials = true;
|
||||
} else if (userCount == 1) {
|
||||
Optional<User> adminUser = userRepository.findByUsernameIgnoreCase("admin");
|
||||
|
||||
if (adminUser.isPresent() && Boolean.TRUE.equals(adminUser.get().getIsFirstLogin())) {
|
||||
isFirstTimeSetup = true;
|
||||
showDefaultCredentials = true;
|
||||
}
|
||||
}
|
||||
|
||||
data.setFirstTimeSetup(isFirstTimeSetup);
|
||||
data.setShowDefaultCredentials(showDefaultCredentials);
|
||||
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
@@ -366,7 +397,8 @@ public class ProprietaryUIDataController {
|
||||
Map<Long, Date> teamLastRequest = new HashMap<>();
|
||||
for (Object[] result : teamActivities) {
|
||||
Long teamId = (Long) result[0];
|
||||
Date lastActivity = (Date) result[1];
|
||||
Instant instant = (Instant) result[1];
|
||||
Date lastActivity = instant != null ? Date.from(instant) : null;
|
||||
teamLastRequest.put(teamId, lastActivity);
|
||||
}
|
||||
|
||||
@@ -410,7 +442,8 @@ public class ProprietaryUIDataController {
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
for (Object[] result : userSessions) {
|
||||
String username = (String) result[0];
|
||||
Date lastRequest = (Date) result[1];
|
||||
Instant instant = (Instant) result[1];
|
||||
Date lastRequest = instant != null ? Date.from(instant) : null;
|
||||
userLastRequest.put(username, lastRequest);
|
||||
}
|
||||
|
||||
@@ -456,6 +489,8 @@ public class ProprietaryUIDataController {
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
private boolean firstTimeSetup;
|
||||
private boolean showDefaultCredentials;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -238,6 +238,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
path = "errorOAuth=oAuth2AutoCreateDisabled";
|
||||
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
|
||||
path = "errorOAuth=oAuth2AdminBlockedUser";
|
||||
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
|
||||
path = "errorOAuth=oAuth2RequiresLicense";
|
||||
} else if (request.getParameter("saml2RequiresLicense") != null) {
|
||||
path = "errorOAuth=saml2RequiresLicense";
|
||||
} else if (request.getParameter("maxUsersReached") != null) {
|
||||
path = "errorOAuth=maxUsersReached";
|
||||
} else if (request.getParameter("userIsDisabled") != null) {
|
||||
path = "errorOAuth=userIsDisabled";
|
||||
} else if ((errorMessage = request.getParameter("error")) != null) {
|
||||
|
||||
@@ -62,6 +62,7 @@ public class InitialSecuritySetup {
|
||||
private void initializeUserLicenseSettings() {
|
||||
licenseSettingsService.initializeGrandfatheredCount();
|
||||
licenseSettingsService.updateLicenseMaxUsers();
|
||||
licenseSettingsService.grandfatherExistingOAuthUsers();
|
||||
}
|
||||
|
||||
private void configureJWTSettings() {
|
||||
|
||||
@@ -23,7 +23,7 @@ public class PremiumEndpointAspect {
|
||||
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
if (!runningProOrHigher) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license");
|
||||
HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license");
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ public class SecurityConfiguration {
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -103,7 +105,9 @@ public class SecurityConfiguration {
|
||||
@Autowired(required = false)
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
@@ -120,10 +124,11 @@ public class SecurityConfiguration {
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
public static PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@@ -354,7 +359,8 @@ public class SecurityConfiguration {
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
@@ -395,7 +401,8 @@ public class SecurityConfiguration {
|
||||
loginAttemptService,
|
||||
securityProperties.getSaml2(),
|
||||
userService,
|
||||
jwtService))
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
.failureHandler(
|
||||
new CustomSaml2AuthenticationFailureHandler())
|
||||
.authenticationRequestResolver(
|
||||
|
||||
@@ -32,7 +32,8 @@ public class EEAppConfig {
|
||||
@Bean(name = "runningProOrHigher")
|
||||
@Primary
|
||||
public boolean runningProOrHigher() {
|
||||
return licenseKeyChecker.getPremiumLicenseEnabledResult() != License.NORMAL;
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
|
||||
@@ -30,7 +30,7 @@ public class KeygenLicenseVerifier {
|
||||
|
||||
public enum License {
|
||||
NORMAL,
|
||||
PRO,
|
||||
SERVER,
|
||||
ENTERPRISE
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ public class KeygenLicenseVerifier {
|
||||
log.info("Detected certificate-based license. Processing...");
|
||||
boolean isValid = verifyCertificateLicense(licenseKeyOrCert, context);
|
||||
if (isValid) {
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
|
||||
} else {
|
||||
license = License.NORMAL;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public class KeygenLicenseVerifier {
|
||||
log.info("Detected JWT-style license key. Processing...");
|
||||
boolean isValid = verifyJWTLicense(licenseKeyOrCert, context);
|
||||
if (isValid) {
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
|
||||
} else {
|
||||
license = License.NORMAL;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public class KeygenLicenseVerifier {
|
||||
log.info("Detected standard license key. Processing...");
|
||||
boolean isValid = verifyStandardLicense(licenseKeyOrCert, context);
|
||||
if (isValid) {
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
|
||||
} else {
|
||||
license = License.NORMAL;
|
||||
}
|
||||
@@ -261,10 +261,23 @@ public class KeygenLicenseVerifier {
|
||||
// Extract metadata
|
||||
JSONObject metadataObj = attributesObj.optJSONObject("metadata");
|
||||
if (metadataObj != null) {
|
||||
int users = metadataObj.optInt("users", 1);
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.info("License allows for {} users", users);
|
||||
// Check if this is an old license (no planType) with isEnterprise flag
|
||||
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false);
|
||||
|
||||
// Extract user count - default based on license type
|
||||
// Old licenses: Only had isEnterprise flag
|
||||
// New licenses: Have planType field with "server" or "enterprise"
|
||||
int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0);
|
||||
|
||||
// SERVER license (isEnterprise=false, users=0) = unlimited
|
||||
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
|
||||
if (!context.isEnterpriseLicense && users == 0) {
|
||||
log.info("SERVER license detected with unlimited users");
|
||||
} else if (context.isEnterpriseLicense && users > 0) {
|
||||
log.info("ENTERPRISE license allows for {} users", users);
|
||||
}
|
||||
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
}
|
||||
|
||||
// Check license status if available
|
||||
@@ -431,28 +444,31 @@ public class KeygenLicenseVerifier {
|
||||
}
|
||||
|
||||
// Extract max users and isEnterprise from policy or metadata
|
||||
int users = policyObj.optInt("users", 1);
|
||||
context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false);
|
||||
int users = policyObj.optInt("users", -1);
|
||||
|
||||
if (users > 0) {
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.info("License allows for {} users", users);
|
||||
} else {
|
||||
// Try to get users from metadata if present
|
||||
if (users == -1) {
|
||||
// Try to get users from metadata if not at policy level
|
||||
Object metadataObj = policyObj.opt("metadata");
|
||||
if (metadataObj instanceof JSONObject metadata) {
|
||||
users = metadata.optInt("users", 1);
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.info("License allows for {} users (from metadata)", users);
|
||||
|
||||
// Check for isEnterprise flag in metadata
|
||||
context.isEnterpriseLicense = metadata.optBoolean("isEnterprise", false);
|
||||
context.isEnterpriseLicense =
|
||||
metadata.optBoolean("isEnterprise", context.isEnterpriseLicense);
|
||||
users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0);
|
||||
} else {
|
||||
// Default value
|
||||
applicationProperties.getPremium().setMaxUsers(1);
|
||||
log.info("Using default of 1 user for license");
|
||||
// Default based on license type
|
||||
users = context.isEnterpriseLicense ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// SERVER license (isEnterprise=false, users=0) = unlimited
|
||||
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
|
||||
if (!context.isEnterpriseLicense && users == 0) {
|
||||
log.info("SERVER license detected with unlimited users");
|
||||
} else if (context.isEnterpriseLicense && users > 0) {
|
||||
log.info("ENTERPRISE license allows for {} users", users);
|
||||
}
|
||||
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -584,17 +600,7 @@ public class KeygenLicenseVerifier {
|
||||
context.maxMachines);
|
||||
}
|
||||
|
||||
// Extract user count, default to 1 if not specified
|
||||
int users =
|
||||
jsonResponse
|
||||
.path("data")
|
||||
.path("attributes")
|
||||
.path("metadata")
|
||||
.path("users")
|
||||
.asInt(1);
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
|
||||
// Extract isEnterprise flag
|
||||
// Extract isEnterprise flag first
|
||||
context.isEnterpriseLicense =
|
||||
jsonResponse
|
||||
.path("data")
|
||||
@@ -603,6 +609,24 @@ public class KeygenLicenseVerifier {
|
||||
.path("isEnterprise")
|
||||
.asBoolean(false);
|
||||
|
||||
// Extract user count - default based on license type
|
||||
int users =
|
||||
jsonResponse
|
||||
.path("data")
|
||||
.path("attributes")
|
||||
.path("metadata")
|
||||
.path("users")
|
||||
.asInt(context.isEnterpriseLicense ? 1 : 0);
|
||||
|
||||
// SERVER license (isEnterprise=false, users=0) = unlimited
|
||||
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
|
||||
if (!context.isEnterpriseLicense && users == 0) {
|
||||
log.info("SERVER license detected with unlimited users");
|
||||
} else if (context.isEnterpriseLicense && users > 0) {
|
||||
log.info("ENTERPRISE license allows for {} users", users);
|
||||
}
|
||||
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.debug(applicationProperties.toString());
|
||||
|
||||
} else {
|
||||
|
||||
@@ -68,8 +68,8 @@ public class LicenseKeyChecker {
|
||||
premiumEnabledResult = licenseService.verifyLicense(licenseKey);
|
||||
if (License.ENTERPRISE == premiumEnabledResult) {
|
||||
log.info("License key is Enterprise.");
|
||||
} else if (License.PRO == premiumEnabledResult) {
|
||||
log.info("License key is Pro.");
|
||||
} else if (License.SERVER == premiumEnabledResult) {
|
||||
log.info("License key is Server.");
|
||||
} else {
|
||||
log.info("License key is invalid, defaulting to non pro license.");
|
||||
}
|
||||
|
||||
@@ -87,6 +87,19 @@ public class UserController {
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
if (licenseSettingsService.wouldExceedLimit(1)) {
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Maximum number of users reached. Allowed: "
|
||||
+ maxAllowed
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
|
||||
@@ -39,4 +39,28 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
long countByTeam(Team team);
|
||||
|
||||
List<User> findAllByTeam(Team team);
|
||||
|
||||
// OAuth grandfathering queries
|
||||
long countBySsoProviderIsNotNull();
|
||||
|
||||
long countByOauthGrandfatheredTrue();
|
||||
|
||||
List<User> findAllBySsoProviderIsNotNull();
|
||||
|
||||
/**
|
||||
* Finds all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
* This catches V1 users who were created via SSO but never signed in (sso_provider is null).
|
||||
*/
|
||||
@Query(
|
||||
"SELECT u FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
List<User> findAllSsoUsers();
|
||||
|
||||
/**
|
||||
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
long countSsoUsers();
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "sso_provider")
|
||||
private String ssoProvider;
|
||||
|
||||
@Column(name = "oauth_grandfathered")
|
||||
private Boolean oauthGrandfathered = false;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@@ -135,4 +138,12 @@ public class User implements UserDetails, Serializable {
|
||||
public boolean hasPassword() {
|
||||
return this.password != null && !this.password.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isOauthGrandfathered() {
|
||||
return oauthGrandfathered != null && oauthGrandfathered;
|
||||
}
|
||||
|
||||
public void setOauthGrandfathered(boolean oauthGrandfathered) {
|
||||
this.oauthGrandfathered = oauthGrandfathered;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -66,6 +68,25 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
username = detailsUser.getUsername();
|
||||
}
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
|
||||
// Check if user is eligible for OAuth (grandfathered or system has paid 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 OAuth login
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||
// No existing user and no paid license -> block auto creation
|
||||
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the saved request
|
||||
HttpSession session = request.getSession(false);
|
||||
String contextPath = request.getContextPath();
|
||||
@@ -91,7 +112,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
return;
|
||||
}
|
||||
if (userService.usernameExistsIgnoreCase(username)
|
||||
if (userExists
|
||||
&& userService.hasPassword(username)
|
||||
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|
||||
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
|
||||
@@ -106,6 +127,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
|
||||
return;
|
||||
}
|
||||
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
|
||||
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
|
||||
return;
|
||||
}
|
||||
if (principal instanceof OAuth2User oAuth2User) {
|
||||
// Extract SSO provider information from OAuth2User
|
||||
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
|
||||
|
||||
@@ -49,6 +49,8 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
private ApplicationProperties.Security.SAML2 saml2Properties;
|
||||
private UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -63,6 +65,26 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String username = saml2Principal.name();
|
||||
log.debug("Authenticated principal found for user: {}", username);
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
|
||||
// Check if user is eligible for SAML (grandfathered or system has paid 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
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||
// No existing user and no paid license -> block auto creation
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
String contextPath = request.getContextPath();
|
||||
SavedRequest savedRequest =
|
||||
@@ -96,7 +118,6 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
boolean hasPassword = userExists && userService.hasPassword(username);
|
||||
boolean isSSOUser =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
|
||||
@@ -129,6 +150,10 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
|
||||
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
|
||||
@@ -734,4 +734,48 @@ public class UserService implements UserServiceInterface {
|
||||
public void saveAll(List<User> users) {
|
||||
userRepository.saveAll(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of OAuth/SAML users. Includes users with sso_provider set OR
|
||||
* authenticationType is sso/oauth2/saml2 (catches V1 users who never signed in).
|
||||
*
|
||||
* @return Count of OAuth users
|
||||
*/
|
||||
public long countOAuthUsers() {
|
||||
return userRepository.countSsoUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of OAuth users who are grandfathered.
|
||||
*
|
||||
* @return Count of grandfathered OAuth users
|
||||
*/
|
||||
public long countGrandfatheredOAuthUsers() {
|
||||
return userRepository.countByOauthGrandfatheredTrue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers all existing OAuth/SAML users. This marks all users with an SSO provider as
|
||||
* grandfathered, allowing them to keep OAuth access even without a paid license.
|
||||
*
|
||||
* @return Number of users updated
|
||||
*/
|
||||
@Transactional
|
||||
public int grandfatherAllOAuthUsers() {
|
||||
List<User> ssoUsers = userRepository.findAllSsoUsers();
|
||||
int updated = 0;
|
||||
|
||||
for (User user : ssoUsers) {
|
||||
if (!user.isOauthGrandfathered()) {
|
||||
user.setOauthGrandfathered(true);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
userRepository.saveAll(ssoUsers);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
|
||||
private boolean hasProOrEnterpriseAccess() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.PRO || license == License.ENTERPRISE;
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.UUID;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -18,6 +19,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
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.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -45,6 +48,7 @@ public class UserLicenseSettingsService {
|
||||
private final UserLicenseSettingsRepository settingsRepository;
|
||||
private final UserService userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectProvider<LicenseKeyChecker> licenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Gets the current user license settings, creating them if they don't exist.
|
||||
@@ -151,7 +155,7 @@ public class UserLicenseSettingsService {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
int licenseMaxUsers = 0;
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
if (hasPaidLicense()) {
|
||||
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
}
|
||||
|
||||
@@ -162,6 +166,40 @@ public class UserLicenseSettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers existing OAuth users on first run. This is a one-time migration that marks all
|
||||
* existing OAuth/SAML users as grandfathered, allowing them to keep OAuth access even without a
|
||||
* paid license.
|
||||
*
|
||||
* <p>New users created after this migration will NOT be grandfathered and will require a paid
|
||||
* license to use OAuth.
|
||||
*/
|
||||
@Transactional
|
||||
public void grandfatherExistingOAuthUsers() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
// Check if we've already run this migration
|
||||
if (settings.getId() != null && settings.isGrandfatheringLocked()) {
|
||||
// Migration should happen at the same time as grandfathering is locked
|
||||
long oauthUsersCount = userService.countOAuthUsers();
|
||||
long grandfatheredCount = userService.countGrandfatheredOAuthUsers();
|
||||
|
||||
if (oauthUsersCount > 0 && grandfatheredCount == 0) {
|
||||
// We have OAuth users but none are grandfathered - this is first run after upgrade
|
||||
int updated = userService.grandfatherAllOAuthUsers();
|
||||
log.warn(
|
||||
"OAuth GRANDFATHERING: Marked {} existing OAuth/SAML users as grandfathered. "
|
||||
+ "They will retain OAuth access even without a paid license. "
|
||||
+ "New users will require a paid license for OAuth.",
|
||||
updated);
|
||||
} else if (grandfatheredCount > 0) {
|
||||
log.debug(
|
||||
"OAuth grandfathering already completed: {} users grandfathered",
|
||||
grandfatheredCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and enforces the integrity of license settings. This ensures that even if someone
|
||||
* manually modifies the database, the grandfathering rules are still enforced.
|
||||
@@ -241,12 +279,15 @@ public class UserLicenseSettingsService {
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Grandfathered limit = max(5, existing user count at initialization)
|
||||
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
|
||||
* <li>If premium disabled: total limit = grandfathered limit
|
||||
* <li>Grandfathered limit = max(5, existing user count at V1→V2 migration)
|
||||
* <li>No license: Uses grandfathered limit only
|
||||
* <li>SERVER license (maxUsers=0): Unlimited users (Integer.MAX_VALUE)
|
||||
* <li>ENTERPRISE license (maxUsers>0): License seats only (NO grandfathering added)
|
||||
* </ul>
|
||||
*
|
||||
* @return Maximum number of users allowed
|
||||
* <p>IMPORTANT: Paid licenses REPLACE the limit, they don't add to grandfathering.
|
||||
*
|
||||
* @return Maximum number of users allowed (Integer.MAX_VALUE for unlimited)
|
||||
*/
|
||||
public int calculateMaxAllowedUsers() {
|
||||
validateSettingsIntegrity();
|
||||
@@ -259,20 +300,52 @@ public class UserLicenseSettingsService {
|
||||
grandfatheredLimit = DEFAULT_USER_LIMIT;
|
||||
}
|
||||
|
||||
int totalLimit = grandfatheredLimit;
|
||||
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
|
||||
// No license: use grandfathered limit
|
||||
if (!hasPaidLicense()) {
|
||||
log.debug("No license: using grandfathered limit of {}", grandfatheredLimit);
|
||||
return grandfatheredLimit;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
|
||||
totalLimit,
|
||||
grandfatheredLimit,
|
||||
settings.getLicenseMaxUsers(),
|
||||
applicationProperties.getPremium().isEnabled());
|
||||
int licenseMaxUsers = settings.getLicenseMaxUsers();
|
||||
|
||||
return totalLimit;
|
||||
// SERVER license (maxUsers=0): unlimited users
|
||||
if (licenseMaxUsers == 0) {
|
||||
log.debug("SERVER license: unlimited users allowed");
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
// ENTERPRISE license (maxUsers>0): license seats only (replaces grandfathering)
|
||||
log.debug(
|
||||
"ENTERPRISE license: {} seats (grandfathered {} not added)",
|
||||
licenseMaxUsers,
|
||||
grandfatheredLimit);
|
||||
return licenseMaxUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is eligible to use OAuth/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 (SSO is enterprise-only)
|
||||
* </ul>
|
||||
*
|
||||
* @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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,4 +481,28 @@ public class UserLicenseSettingsService {
|
||||
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasPaidLicense() {
|
||||
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
|
||||
if (checker == null) {
|
||||
return false;
|
||||
}
|
||||
License license = checker.getPremiumLicenseEnabledResult();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO
|
||||
* (OAuth/SAML).
|
||||
*
|
||||
* @return true if ENTERPRISE license is active
|
||||
*/
|
||||
private boolean hasEnterpriseLicense() {
|
||||
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
|
||||
if (checker == null) {
|
||||
return false;
|
||||
}
|
||||
License license = checker.getPremiumLicenseEnabledResult();
|
||||
return license == License.ENTERPRISE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
@@ -208,6 +211,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
@@ -237,6 +243,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
|
||||
@@ -44,13 +44,13 @@ class LicenseKeyCheckerTest {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getPremium().setEnabled(true);
|
||||
props.getPremium().setKey("abc");
|
||||
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
|
||||
when(verifier.verifyLicense("abc")).thenReturn(License.SERVER);
|
||||
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
|
||||
assertEquals(License.SERVER, checker.getPremiumLicenseEnabledResult());
|
||||
verify(verifier).verifyLicense("abc");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
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.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class UserLicenseSettingsServiceTest {
|
||||
|
||||
@Mock private UserLicenseSettingsRepository settingsRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.Premium premium;
|
||||
@Mock private LicenseKeyChecker licenseKeyChecker;
|
||||
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
|
||||
|
||||
private UserLicenseSettingsService service;
|
||||
private UserLicenseSettings mockSettings;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockSettings = new UserLicenseSettings();
|
||||
mockSettings.setId(1L);
|
||||
mockSettings.setGrandfatheredUserCount(80);
|
||||
mockSettings.setGrandfatheringLocked(true);
|
||||
mockSettings.setIntegritySalt("test-salt");
|
||||
mockSettings.setGrandfatheredUserSignature("80:test-signature");
|
||||
|
||||
when(applicationProperties.getPremium()).thenReturn(premium);
|
||||
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
|
||||
when(userService.getTotalUsersCount()).thenReturn(80L);
|
||||
when(settingsRepository.save(any(UserLicenseSettings.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(licenseKeyChecker);
|
||||
|
||||
// Create service with overridden validateSettingsIntegrity to bypass signature validation
|
||||
service =
|
||||
new UserLicenseSettingsService(
|
||||
settingsRepository,
|
||||
userService,
|
||||
applicationProperties,
|
||||
licenseKeyCheckerProvider) {
|
||||
@Override
|
||||
public void validateSettingsIntegrity() {
|
||||
// Override to do nothing in tests - avoid HMAC signature validation
|
||||
// complexity
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLicense_returnsGrandfatheredLimit() {
|
||||
// No license active
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(80, result, "Should return grandfathered user count when no license");
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverLicense_returnsUnlimited() {
|
||||
// SERVER license with users=0
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
mockSettings.setLicenseMaxUsers(0);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(Integer.MAX_VALUE, result, "SERVER license should return unlimited users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enterpriseLicense_returnsLicenseSeatsOnly() {
|
||||
// ENTERPRISE license with 5 seats
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(5);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
5,
|
||||
result,
|
||||
"ENTERPRISE license should return license seats only (NOT grandfathered + seats)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enterpriseLicense_ignoresGrandfathering() {
|
||||
// ENTERPRISE with 20 seats, grandfathered was 80
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(20);
|
||||
mockSettings.setGrandfatheredUserCount(80); // This should be ignored
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
20,
|
||||
result,
|
||||
"ENTERPRISE license should ignore grandfathering and use license seats only");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstall_noLicense_returnsFive() {
|
||||
// Fresh install with default 5 users grandfathered
|
||||
mockSettings.setGrandfatheredUserCount(5);
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(5, result, "Fresh install with no license should return 5 users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstall_serverLicense_returnsUnlimited() {
|
||||
// Fresh install with SERVER license
|
||||
mockSettings.setGrandfatheredUserCount(5);
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
mockSettings.setLicenseMaxUsers(0);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
Integer.MAX_VALUE,
|
||||
result,
|
||||
"Fresh install with SERVER license should return unlimited");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstall_enterpriseLicense_returnsLicenseSeats() {
|
||||
// Fresh install with ENTERPRISE 10 seats
|
||||
mockSettings.setGrandfatheredUserCount(5);
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(10);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
10, result, "Fresh install with ENTERPRISE license should return license seats");
|
||||
}
|
||||
|
||||
@Test
|
||||
void v1MigrationWith80Users_noLicense_returns80() {
|
||||
// V1→V2 migration with 80 users, no paid license
|
||||
mockSettings.setGrandfatheredUserCount(80);
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(80, result, "V1→V2 migration should preserve 80 grandfathered users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void v1MigrationWith80Users_thenEnterpriseWith5Seats_returns5() {
|
||||
// V1→V2 with 80 users, then buy ENTERPRISE 5 seats
|
||||
mockSettings.setGrandfatheredUserCount(80);
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(5);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
5, result, "ENTERPRISE 5 seats should override grandfathered 80 users (not 85)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroGrandfathered_fallsBackToDefault() {
|
||||
// Edge case: grandfathered is 0 (should not happen)
|
||||
mockSettings.setGrandfatheredUserCount(0);
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="/modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<title>Stirling PDF</title>
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
@@ -57,8 +57,8 @@
|
||||
"posthog-js": "^1.268.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tailwindcss": "^4.1.13",
|
||||
@@ -117,7 +117,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify-json/material-symbols": "^1.2.48",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -1,12 +1,11 @@
|
||||
<svg width="82" height="92" viewBox="0 0 82 92" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_dddd_3933_98714)">
|
||||
<path d="M56.3678 0.557543L9.07973 10.0315C4.2844 10.989 0.875 14.8441 0.875 19.3039V73.9053C0.875 77.7856 4.06265 81.0359 8.27589 81.4895L33.0841 84.4375L54.9327 64.6642L77.1014 44.0219L76.7133 12.778C76.6579 8.97326 72.7773 6.15122 68.7027 6.95752L63.2143 8.04098L63.2421 5.67248C63.2975 2.34651 59.9158 -0.147965 56.3678 0.557543Z" fill="white"/>
|
||||
<path d="M63.269 8.04043V57.5772L77.1561 43.9962L76.7681 11.7192C76.7403 8.44358 73.3864 6.02469 69.8661 6.7302L63.269 8.04043Z" fill="#D3D3D3"/>
|
||||
<path d="M33.0874 84.4375L70.2191 75.6383L77.157 43.9963L33.0874 84.4375Z" fill="#D0DBDC"/>
|
||||
<path d="M44.1432 35.3049C44.1432 35.3049 39.9022 30.2907 36.77 28.9301C34.7743 28.0734 31.8084 27.7459 29.48 28.8041C26.0429 30.3663 25.6826 34.7506 28.8425 36.4891C29.5909 36.9175 30.5888 37.2702 31.8915 37.5474C38.8767 39.0088 50.4076 42.0324 48.994 54.2024C48.994 54.2024 48.1901 65.6166 34.9683 66.9268C32.6399 67.1536 30.2839 67.0024 28.0109 66.5236C24.8787 65.8937 19.335 64.6087 16.6185 63.0717L16.1196 50.045H17.4501C17.4501 50.045 20.0834 55.8402 27.5674 58.6371C28.9534 59.141 30.4779 59.3426 31.947 59.141C33.8041 58.889 35.9939 57.982 36.8809 55.2355C36.8809 55.2355 38.3777 51.2292 30.6165 49.0371C24.4075 47.2733 18.5589 45.4592 16.1473 39.5127C15.0109 36.6907 14.8168 33.5663 15.593 30.5679C16.6185 26.6624 19.5567 21.1695 27.8723 18.801C27.8723 18.801 38.7935 16.105 46.2776 19.7585L45.9449 35.3049H44.1432Z" fill="#171A1F"/>
|
||||
<path d="M56.3682 0.557473C59.916 -0.147829 63.2976 2.34683 63.2422 5.67271L63.2139 8.04087L68.7031 6.95689C72.7776 6.15095 76.6575 8.97364 76.7129 12.7782L77.1016 44.0213L54.9326 64.6639L33.084 84.4374L8.27637 81.4891C4.06313 81.0356 0.875008 77.7854 0.875 73.9051V19.3036C0.875108 14.8438 4.28482 10.9886 9.08008 10.0311L56.3682 0.557473ZM46.2773 19.7586C38.7933 16.1052 27.8721 18.8006 27.8721 18.8006C19.5566 21.1692 16.6184 26.6627 15.5928 30.5682C14.8168 33.5665 15.0111 36.6907 16.1475 39.5126C18.559 45.4589 24.4073 47.2732 30.6162 49.037C38.3528 51.2221 36.8902 55.2099 36.8809 55.2352C35.9939 57.9815 33.8044 58.8885 31.9473 59.1405C30.4782 59.3421 28.9533 59.1405 27.5674 58.6366C20.0835 55.8397 17.4502 50.0448 17.4502 50.0448H16.1191L16.6182 63.0711C19.3345 64.6081 24.8785 65.8934 28.0107 66.5233C30.2837 67.002 32.6404 67.1534 34.9688 66.9266C48.1902 65.6162 48.9941 54.202 48.9941 54.202C50.4076 42.0323 38.8767 39.0091 31.8916 37.5477C30.589 37.2706 29.5911 36.9174 28.8428 36.4891C25.6828 34.7505 26.0434 30.3658 29.4805 28.8036C31.8088 27.7456 34.7749 28.0729 36.7705 28.9295C39.9027 30.2903 44.1436 35.3045 44.1436 35.3045H45.9453L46.2773 19.7586Z" fill="white"/>
|
||||
<path d="M63.2779 8.03641L63.2781 57.7846L77.1651 44.2035L76.7771 11.9265C76.7494 8.65091 73.3953 6.02067 69.875 6.72618L63.2779 8.03641Z" fill="#D3D3D3"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dddd_3933_98714" x="-8.34465e-07" y="-4.17233e-07" width="81.0945" height="91.875" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<filter id="filter0_dddd_3933_98714" x="-8.34465e-07" y="-4.17233e-07" width="81.1025" height="91.875" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.4375"/>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg width="77" height="84" viewBox="0 0 77 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.2124 83.9998L69.3441 75.2006L76.282 43.5586L32.2124 83.9998Z" fill="#D0DBDC"/>
|
||||
<path d="M55.4932 0.119997C59.0409 -0.585291 62.4223 1.90861 62.3672 5.23426L62.3389 7.6034L67.8281 6.51941C71.9024 5.71349 75.7821 8.53542 75.8379 12.3397L76.2266 43.5839L54.0576 64.2264L32.209 83.9999L7.40137 81.0516C3.18818 80.5981 8.70906e-05 77.3479 0 73.4677V18.8661C3.4631e-05 14.4063 3.40977 10.5511 8.20508 9.59363L55.4932 0.119997ZM45.4023 19.3212C37.9183 15.6676 26.9971 18.3632 26.9971 18.3632C18.6818 20.7316 15.7434 26.2243 14.7178 30.1298C13.9417 33.1282 14.136 36.253 15.2725 39.0751C17.684 45.0213 23.5324 46.8357 29.7412 48.5995C37.5024 50.7916 36.0059 54.7977 36.0059 54.7977C35.1189 57.544 32.9294 58.451 31.0723 58.703C29.6032 58.9046 28.0783 58.703 26.6924 58.1991C19.2266 55.4091 16.588 49.6354 16.5752 49.6073H15.2441L15.7432 62.6337C18.4595 64.1706 24.0035 65.4559 27.1357 66.0858C29.4087 66.5646 31.7654 66.7159 34.0938 66.4891C47.3152 65.1787 48.1191 53.7645 48.1191 53.7645C49.5327 41.5946 38.0017 38.5707 31.0166 37.1093C29.714 36.8321 28.7161 36.4799 27.9678 36.0516C24.8078 34.3131 25.1683 29.9283 28.6055 28.3661C30.9338 27.3081 33.8999 27.6354 35.8955 28.4921C39.0277 29.8529 43.2686 34.8671 43.2686 34.8671H45.0703L45.4023 19.3212Z" fill="white"/>
|
||||
<path d="M62.394 7.60275V57.1395L76.2811 43.5585L75.8931 11.2815C75.8653 8.0059 72.5114 5.58701 68.9911 6.29252L62.394 7.60275Z" fill="#EEEEEE"/>
|
||||
<path d="M32.2124 83.9997L69.3441 75.2005L76.282 43.5585L32.2124 83.9997Z" fill="#D0DBDC"/>
|
||||
<path d="M55.4932 0.119995C59.0409 -0.585293 62.4223 1.9086 62.3672 5.23425L62.3389 7.60339L67.8281 6.51941C71.9024 5.71349 75.7821 8.53542 75.8379 12.3397L76.2266 43.5839L54.0576 64.2264L32.209 83.9999L7.40137 81.0516C3.18818 80.5981 8.70906e-05 77.3479 0 73.4677V18.8661C3.4631e-05 14.4063 3.40977 10.5511 8.20508 9.59363L55.4932 0.119995ZM45.4023 19.3212C37.9183 15.6676 26.9971 18.3632 26.9971 18.3632C18.6818 20.7316 15.7434 26.2243 14.7178 30.1298C13.9417 33.1282 14.136 36.253 15.2725 39.0751C17.684 45.0213 23.5324 46.8357 29.7412 48.5995C37.5024 50.7916 36.0059 54.7977 36.0059 54.7977C35.1189 57.544 32.9294 58.451 31.0723 58.703C29.6032 58.9046 28.0783 58.703 26.6924 58.1991C19.2266 55.4091 16.588 49.6354 16.5752 49.6073H15.2441L15.7432 62.6337C18.4595 64.1706 24.0035 65.4559 27.1357 66.0858C29.4087 66.5646 31.7654 66.7159 34.0938 66.4891C47.3152 65.1787 48.1191 53.7645 48.1191 53.7645C49.5327 41.5946 38.0017 38.5707 31.0166 37.1093C29.714 36.8321 28.7161 36.4799 27.9678 36.0516C24.8078 34.3131 25.1684 29.9283 28.6055 28.3661C30.9338 27.3081 33.8999 27.6354 35.8955 28.4921C39.0277 29.8529 43.2686 34.8671 43.2686 34.8671H45.0703L45.4023 19.3212Z" fill="white"/>
|
||||
<path d="M62.394 7.60263V57.1394L76.2811 43.5584L75.8931 11.2814C75.8653 8.00577 72.5114 5.58689 68.9911 6.2924L62.394 7.60263Z" fill="#EEEEEE"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -40,6 +40,11 @@
|
||||
"discardChanges": "Discard & Leave",
|
||||
"applyAndContinue": "Save & Leave",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"backendStartup": {
|
||||
"notFoundTitle": "Backend not found",
|
||||
"retry": "Retry",
|
||||
"unreachable": "The application cannot currently connect to the backend. Verify the backend status and network connectivity, then try again."
|
||||
},
|
||||
"zipWarning": {
|
||||
"title": "Large ZIP File",
|
||||
"message": "This ZIP contains {{count}} files. Extract anyway?",
|
||||
@@ -63,7 +68,7 @@
|
||||
},
|
||||
"settingsOpened": {
|
||||
"title": "Settings Opened",
|
||||
"message": "Please select Stirling PDF in your system settings"
|
||||
"message": "In Windows Settings, search for 'PDF' and select Stirling PDF as your default app"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error",
|
||||
@@ -130,6 +135,8 @@
|
||||
"save": "Save",
|
||||
"saveToBrowser": "Save to Browser",
|
||||
"download": "Download",
|
||||
"downloadUnavailable": "Download unavailable for this item",
|
||||
"saveUnavailable": "Save unavailable for this item",
|
||||
"pin": "Pin File (keep active after tool run)",
|
||||
"unpin": "Unpin File (replace after tool run)",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
@@ -292,7 +299,8 @@
|
||||
"learnMore": "Learn more",
|
||||
"enable": "Enable analytics",
|
||||
"disable": "Disable analytics",
|
||||
"settings": "You can change the settings for analytics in the config/settings.yml file"
|
||||
"settings": "You can change the settings for analytics in the config/settings.yml file",
|
||||
"privacyAssurance": "We do not track any personal information or the contents of your files."
|
||||
},
|
||||
"navbar": {
|
||||
"favorite": "Favorites",
|
||||
@@ -363,6 +371,12 @@
|
||||
"fullscreen": "Fullscreen",
|
||||
"sidebar": "Sidebar"
|
||||
},
|
||||
"logoVariant": {
|
||||
"label": "Interface logo",
|
||||
"description": "Override the system default logo style for this device only. Controls whether Stirling PDF uses the modern or classic logo across the interface, login header, landing page, and favicons.",
|
||||
"modern": "Modern",
|
||||
"classic": "Classic"
|
||||
},
|
||||
"defaultPdfEditor": "Default PDF editor",
|
||||
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
|
||||
"defaultPdfEditorInactive": "Another application is set as default",
|
||||
@@ -872,7 +886,8 @@
|
||||
},
|
||||
"landing": {
|
||||
"addFiles": "Add Files",
|
||||
"uploadFromComputer": "Upload from computer"
|
||||
"uploadFromComputer": "Upload from computer",
|
||||
"openFromComputer": "Open from computer"
|
||||
},
|
||||
"viewPdf": {
|
||||
"tags": "view,read,annotate,text,image,highlight,edit",
|
||||
@@ -3463,6 +3478,9 @@
|
||||
"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.",
|
||||
"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",
|
||||
"oauth2invalidRequest": "Invalid Request",
|
||||
@@ -3506,7 +3524,26 @@
|
||||
"unexpectedError": "Unexpected error: {{message}}",
|
||||
"accountCreatedSuccess": "Account created successfully! You can now sign in.",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.",
|
||||
"credentialsUpdated": "Your credentials have been updated. Please sign in again."
|
||||
"credentialsUpdated": "Your credentials have been updated. Please sign in again.",
|
||||
"defaultCredentials": "Default Login Credentials",
|
||||
"changePasswordWarning": "Please change your password after logging in for the first time",
|
||||
"slides": {
|
||||
"overview": {
|
||||
"alt": "Stirling PDF overview",
|
||||
"title": "Your one-stop-shop for all your PDF needs.",
|
||||
"subtitle": "A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools."
|
||||
},
|
||||
"edit": {
|
||||
"alt": "Edit PDFs",
|
||||
"title": "Edit PDFs to display/secure the information you want",
|
||||
"subtitle": "With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for."
|
||||
},
|
||||
"secure": {
|
||||
"alt": "Secure PDFs",
|
||||
"title": "Protect sensitive information in your PDFs",
|
||||
"subtitle": "Add passwords, redact content, and manage certificates with ease."
|
||||
}
|
||||
}
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
@@ -3858,14 +3895,17 @@
|
||||
"actualSize": "Actual Size"
|
||||
},
|
||||
"viewer": {
|
||||
"cannotPreviewFile": "Cannot Preview File",
|
||||
"dualPageView": "Dual Page View",
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"previousPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"onlyPdfSupported": "The viewer only supports PDF files. This file appears to be a different format.",
|
||||
"previousPage": "Previous Page",
|
||||
"singlePageView": "Single Page View",
|
||||
"dualPageView": "Dual Page View"
|
||||
"unknownFile": "Unknown file",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out"
|
||||
},
|
||||
"rightRail": {
|
||||
"closeSelected": "Close Selected Files",
|
||||
@@ -3877,6 +3917,7 @@
|
||||
"exportAll": "Export PDF",
|
||||
"downloadSelected": "Download Selected Files",
|
||||
"downloadAll": "Download All",
|
||||
"saveAll": "Save All",
|
||||
"toggleTheme": "Toggle Theme",
|
||||
"toggleBookmarks": "Toggle Bookmarks",
|
||||
"language": "Language",
|
||||
@@ -3965,8 +4006,16 @@
|
||||
"saved": "Settings saved successfully",
|
||||
"saveSuccess": "Settings saved successfully",
|
||||
"save": "Save Changes",
|
||||
"discard": "Discard",
|
||||
"restartRequired": "Restart Required",
|
||||
"loginRequired": "Login mode must be enabled to modify admin settings",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Do you want to discard them?",
|
||||
"cancel": "Keep Editing",
|
||||
"discard": "Discard Changes",
|
||||
"hint": "You have unsaved changes"
|
||||
},
|
||||
"loginDisabled": {
|
||||
"title": "Login Mode Required",
|
||||
"message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
|
||||
@@ -4021,7 +4070,8 @@
|
||||
},
|
||||
"languages": {
|
||||
"label": "Available Languages",
|
||||
"description": "Languages that users can select from (leave empty to enable all languages)"
|
||||
"description": "Languages that users can select from (leave empty to enable all languages)",
|
||||
"placeholder": "Select languages"
|
||||
},
|
||||
"customMetadata": {
|
||||
"label": "Custom Metadata",
|
||||
@@ -4044,9 +4094,11 @@
|
||||
},
|
||||
"logoStyle": {
|
||||
"label": "Logo Style",
|
||||
"description": "Choose between the modern minimalist logo or the classic S icon",
|
||||
"description": "Set the default logo style for all users on this server. Users can override this setting in their personal preferences.",
|
||||
"classic": "Classic",
|
||||
"modern": "Modern"
|
||||
"classicAlt": "Classic logo",
|
||||
"modern": "Modern",
|
||||
"modernAlt": "Modern logo"
|
||||
},
|
||||
"customPaths": {
|
||||
"label": "Custom Paths",
|
||||
@@ -4570,12 +4622,17 @@
|
||||
"or": "or",
|
||||
"dropFileHere": "Drop file here or click to upload",
|
||||
"dropFilesHere": "Drop files here or click the upload button",
|
||||
"dropFilesHereOpen": "Drop files here or click the open button",
|
||||
"pdfFilesOnly": "PDF files only",
|
||||
"supportedFileTypes": "Supported file types",
|
||||
"upload": "Upload",
|
||||
"uploadFile": "Upload File",
|
||||
"uploadFiles": "Upload Files",
|
||||
"open": "Open",
|
||||
"openFile": "Open File",
|
||||
"openFiles": "Open Files",
|
||||
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
||||
"noFilesInStorageOpen": "No files available in storage. Open some files first.",
|
||||
"selectFromStorage": "Select from Storage",
|
||||
"backToTools": "Back to Tools",
|
||||
"addFiles": "Add Files",
|
||||
@@ -4621,6 +4678,8 @@
|
||||
"myFiles": "My Files",
|
||||
"noRecentFiles": "No recent files found",
|
||||
"googleDriveNotAvailable": "Google Drive integration not available",
|
||||
"downloadSelected": "Download Selected",
|
||||
"saveSelected": "Save Selected",
|
||||
"openFiles": "Open Files",
|
||||
"openFile": "Open File",
|
||||
"details": "File Details",
|
||||
@@ -4638,7 +4697,9 @@
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"unsupported": "Unsupported",
|
||||
"active": "Active",
|
||||
"addToUpload": "Add to Upload",
|
||||
"closeFile": "Close File",
|
||||
"deleteAll": "Delete All",
|
||||
"loadingFiles": "Loading files...",
|
||||
"noFiles": "No files available",
|
||||
@@ -4947,7 +5008,16 @@
|
||||
"config": {
|
||||
"overview": {
|
||||
"title": "Application Configuration",
|
||||
"description": "Current application settings and configuration details."
|
||||
"description": "Current application settings and configuration details.",
|
||||
"loading": "Loading configuration...",
|
||||
"error": "Error",
|
||||
"warning": "Configuration Warning",
|
||||
"sections": {
|
||||
"basic": "Basic Configuration",
|
||||
"security": "Security Configuration",
|
||||
"system": "System Configuration",
|
||||
"integration": "Integration Configuration"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"overview": {
|
||||
@@ -5112,6 +5182,10 @@
|
||||
"maybeLater": "Maybe Later",
|
||||
"dontShowAgain": "Don't Show Again"
|
||||
},
|
||||
"welcomeSlide": {
|
||||
"title": "Welcome to Stirling",
|
||||
"body": "Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - <strong>Edit Text</strong>."
|
||||
},
|
||||
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||
@@ -5134,7 +5208,39 @@
|
||||
"next": "Next",
|
||||
"finish": "Finish",
|
||||
"startTour": "Start Tour",
|
||||
"startTourDescription": "Take a guided tour of Stirling PDF's key features"
|
||||
"startTourDescription": "Take a guided tour of Stirling PDF's key features",
|
||||
"buttons": {
|
||||
"next": "Next →",
|
||||
"back": "Back",
|
||||
"skipForNow": "Skip for now",
|
||||
"download": "Download →",
|
||||
"showMeAround": "Show me around",
|
||||
"skipTheTour": "Skip the tour"
|
||||
},
|
||||
"serverLicense": {
|
||||
"skip": "Skip for now",
|
||||
"seePlans": "See Plans →",
|
||||
"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."
|
||||
},
|
||||
"desktopInstall": {
|
||||
"title": "Download",
|
||||
"titleWithOs": "Download for {{osLabel}}",
|
||||
"body": "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer."
|
||||
},
|
||||
"planOverview": {
|
||||
"adminTitle": "Admin Overview",
|
||||
"userTitle": "Plan Overview",
|
||||
"adminBodyLoginEnabled": "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.",
|
||||
"adminBodyLoginDisabled": "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.",
|
||||
"userBody": "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use."
|
||||
},
|
||||
"securityCheck": {
|
||||
"message": "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
|
||||
}
|
||||
},
|
||||
"adminOnboarding": {
|
||||
"welcome": "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
|
||||
@@ -5163,7 +5269,10 @@
|
||||
"role": "Role",
|
||||
"team": "Team",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"actions": {
|
||||
"label": "Actions",
|
||||
"upgrade": "Upgrade"
|
||||
},
|
||||
"noMembersFound": "No members found",
|
||||
"active": "Active",
|
||||
"disabled": "Disabled",
|
||||
@@ -5275,13 +5384,15 @@
|
||||
"slotsAvailable": "{{count}} user slot(s) available",
|
||||
"noSlotsAvailable": "No slots available",
|
||||
"currentUsage": "Currently using {{current}} of {{max}} user licences"
|
||||
}
|
||||
},
|
||||
"loginRequired": "Enable login mode first"
|
||||
},
|
||||
"teams": {
|
||||
"title": "Teams",
|
||||
"description": "Manage teams and organize workspace members",
|
||||
"loading": "Loading teams...",
|
||||
"loadingDetails": "Loading team details...",
|
||||
"loadError": "Failed to load team details",
|
||||
"createNewTeam": "Create New Team",
|
||||
"teamName": "Team Name",
|
||||
"totalMembers": "Total Members",
|
||||
@@ -5429,6 +5540,12 @@
|
||||
"api": "API Access",
|
||||
"priority": "Priority Support",
|
||||
"customPricing": "Custom Pricing"
|
||||
},
|
||||
"licenseWarning": {
|
||||
"title": "Free self-hosted limit reached",
|
||||
"body": "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly.",
|
||||
"overLimit": "more than {{limit}}",
|
||||
"cta": "See plans"
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
@@ -5470,10 +5587,15 @@
|
||||
"title": "Upgrade to Server Plan",
|
||||
"message": "Get the most out of Stirling PDF with unlimited users and advanced features",
|
||||
"upgradeButton": "Upgrade Now",
|
||||
"dismiss": "Dismiss banner"
|
||||
"dismiss": "Dismiss banner",
|
||||
"attentionTitle": "This server needs admin attention",
|
||||
"attentionBody": "Your admin needs to sign in to see more info. Please contact them immediately.",
|
||||
"attentionBodyAdmin": "Review the license requirements to keep this server compliant.",
|
||||
"seeInfo": "See info"
|
||||
},
|
||||
"payment": {
|
||||
"preparing": "Preparing your checkout...",
|
||||
"redirecting": "Redirecting to secure checkout...",
|
||||
"upgradeTitle": "Upgrade to {{planName}}",
|
||||
"success": "Payment Successful!",
|
||||
"successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.",
|
||||
@@ -5769,18 +5891,77 @@
|
||||
"label": "Username",
|
||||
"placeholder": "Enter your username"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"placeholder": "Enter your email"
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "Enter your password"
|
||||
},
|
||||
"error": {
|
||||
"emptyUsername": "Please enter your username",
|
||||
"emptyPassword": "Please enter your password"
|
||||
"emptyEmail": "Please enter your email",
|
||||
"emptyPassword": "Please enter your password",
|
||||
"oauthFailed": "OAuth login failed. Please try again."
|
||||
},
|
||||
"submit": "Login"
|
||||
"submit": "Login",
|
||||
"signInWith": "Sign in with",
|
||||
"oauthPending": "Opening browser for authentication...",
|
||||
"orContinueWith": "Or continue with email"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"success": {
|
||||
"title": "Authentication Successful",
|
||||
"message": "You can close this window and return to Stirling PDF."
|
||||
},
|
||||
"error": {
|
||||
"title": "Authentication Failed",
|
||||
"message": "Authentication was not successful. You can close this window and try again."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"preferences": {
|
||||
"title": "Preferences"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace",
|
||||
"people": "People",
|
||||
"teams": "Teams"
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Configuration",
|
||||
"systemSettings": "System Settings",
|
||||
"features": "Features",
|
||||
"endpoints": "Endpoints",
|
||||
"database": "Database",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"securityAuth": {
|
||||
"title": "Security & Authentication",
|
||||
"security": "Security",
|
||||
"connections": "Connections"
|
||||
},
|
||||
"licensingAnalytics": {
|
||||
"title": "Licensing & Analytics",
|
||||
"plan": "Plan",
|
||||
"audit": "Audit",
|
||||
"usageAnalytics": "Usage Analytics"
|
||||
},
|
||||
"policiesPrivacy": {
|
||||
"title": "Policies & Privacy",
|
||||
"legal": "Legal",
|
||||
"privacy": "Privacy"
|
||||
},
|
||||
"developer": {
|
||||
"title": "Developer",
|
||||
"apiKeys": "API Keys"
|
||||
},
|
||||
"tooltips": {
|
||||
"enableLoginFirst": "Enable login mode first",
|
||||
"requiresEnterprise": "Requires Enterprise license"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection Mode",
|
||||
"mode": {
|
||||
@@ -5837,7 +6018,13 @@
|
||||
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"description": "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
"errorModifier": {
|
||||
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
|
||||
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||
},
|
||||
"searchPlaceholder": "Search tools...",
|
||||
"none": "Not assigned",
|
||||
"customBadge": "Custom",
|
||||
@@ -6018,4 +6205,4 @@
|
||||
"tags": "text,annotation,label",
|
||||
"applySignatures": "Apply Text"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"short_name": "Stirling PDF",
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "classic-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "classic-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "classic-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"src": "modern-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"src": "modern-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"src": "modern-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#E6E6E6" fill-opacity="0.4"/>
|
||||
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#E6E6E6" fill-opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#ACACAC" fill-opacity="0.3"/>
|
||||
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#FC9999" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" fill="none">
|
||||
<path d="M7.26375 95.8344L123.374 4.32822e-05L123.375 89.4987L7.26375 185.333L7.26375 95.8344Z" fill="white"/>
|
||||
<path d="M68.4794 102.395L184.728 6.44717L184.728 96.052L68.4794 192L68.4794 102.395Z" fill="white" fill-opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 53 KiB |