Compare commits
14
Commits
v2.1.1
...
fix/swagger-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd6b12c14 | ||
|
|
ad84ebc851 | ||
|
|
cc3d8fb1ae | ||
|
|
a0a8f6e6b6 | ||
|
|
10ac098775 | ||
|
|
f17ad56def | ||
|
|
de438d00e1 | ||
|
|
6787169583 | ||
|
|
291e1a392b | ||
|
|
7c5aa3685f | ||
|
|
9c03914edd | ||
|
|
c980ee10c0 | ||
|
|
fa4d2bc09a | ||
|
|
7faf7e50fa |
@@ -574,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -58,6 +58,7 @@ public class InitialSetup {
|
||||
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
|
||||
}
|
||||
}
|
||||
|
||||
public void initLegalUrls() throws IOException {
|
||||
// Initialize Terms and Conditions
|
||||
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+64
-2
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
-1
@@ -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
|
||||
|
||||
+27
-2
@@ -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;
|
||||
|
||||
+77
@@ -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(
|
||||
|
||||
+8
-18
@@ -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");
|
||||
|
||||
-18
@@ -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();
|
||||
}
|
||||
|
||||
+49
@@ -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;\">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(username, passwordSection, loginUrl, loginUrl);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.1.1'
|
||||
version = '2.1.2'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -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 = "Λήψη"
|
||||
|
||||
@@ -5263,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"
|
||||
@@ -5368,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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "下載"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -334,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<{
|
||||
@@ -614,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;
|
||||
@@ -2279,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') {
|
||||
@@ -2300,6 +2317,7 @@ const selectionToolbarPosition = useMemo(() => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
composingGroupsRef.current.delete(group.id);
|
||||
syncEditorValue(event.currentTarget, group.pageIndex, group.id, {
|
||||
skipCaretRestore: true,
|
||||
});
|
||||
@@ -2309,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={{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const devApiLink = "/swagger-ui/index.html";
|
||||
@@ -20,6 +20,7 @@ export interface AppConfig {
|
||||
serverPort?: number;
|
||||
appNameNavbar?: string;
|
||||
languages?: string[];
|
||||
defaultLocale?: string;
|
||||
logoStyle?: 'modern' | 'classic';
|
||||
enableLogin?: boolean;
|
||||
enableEmailInvites?: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
@@ -110,7 +111,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
|
||||
|
||||
const loadSampleFile = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('samples/Sample.pdf');
|
||||
const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { devApiLink } from "@app/constants/links";
|
||||
import SplitPdfPanel from "@app/tools/Split";
|
||||
import CompressPdfPanel from "@app/tools/Compress";
|
||||
import OCRPanel from "@app/tools/OCR";
|
||||
@@ -784,7 +785,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.devApi.desc", "Link to API documentation"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
|
||||
link: devApiLink,
|
||||
synonyms: getSynonyms(t, "devApi"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
|
||||
+76
-15
@@ -105,33 +105,94 @@ i18n.on('languageChanged', (lng) => {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
export function normalizeLanguageCode(languageCode: string): string {
|
||||
// Replace underscores with hyphens to align with i18next/translation file naming
|
||||
const hyphenated = languageCode.replace(/_/g, '-');
|
||||
const [base, ...rest] = hyphenated.split('-');
|
||||
|
||||
if (rest.length === 0) {
|
||||
return base.toLowerCase();
|
||||
}
|
||||
|
||||
const normalizedParts = rest.map(part => (part.length <= 3 ? part.toUpperCase() : part));
|
||||
return [base.toLowerCase(), ...normalizedParts].join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert language codes to underscore format (e.g., en-GB → en_GB)
|
||||
* Used for backend API communication which expects underscore format
|
||||
*/
|
||||
export function toUnderscoreFormat(languageCode: string): string {
|
||||
return languageCode.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of language codes to underscore format
|
||||
*/
|
||||
export function toUnderscoreLanguages(languages: string[]): string[] {
|
||||
return languages.map(toUnderscoreFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the supported languages list dynamically based on config
|
||||
* If configLanguages is null/empty, all languages remain available
|
||||
* Otherwise, only specified languages plus 'en-GB' fallback are enabled
|
||||
* Otherwise, only the specified languages are enabled with the first valid
|
||||
* option (preferring en-GB when present) used as the fallback language.
|
||||
*
|
||||
* @param configLanguages - Optional array of language codes from server config (ui.languages)
|
||||
* @param defaultLocale - Optional default language for new users (system.defaultLocale)
|
||||
*/
|
||||
export function updateSupportedLanguages(configLanguages?: string[] | null) {
|
||||
export function updateSupportedLanguages(configLanguages?: string[] | null, defaultLocale?: string | null) {
|
||||
// Normalize and validate default locale if provided
|
||||
const normalizedDefault = defaultLocale ? normalizeLanguageCode(defaultLocale) : null;
|
||||
const validDefault = normalizedDefault && normalizedDefault in supportedLanguages ? normalizedDefault : null;
|
||||
|
||||
if (!configLanguages || configLanguages.length === 0) {
|
||||
// No filter specified - keep all languages
|
||||
// But still apply default locale if provided and user has no preference
|
||||
if (validDefault) {
|
||||
applyDefaultLocale(validDefault);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure fallback language is always included
|
||||
const languagesToSupport = new Set(['en-GB', ...configLanguages]);
|
||||
const validLanguages = configLanguages
|
||||
.map(normalizeLanguageCode)
|
||||
.filter(lang => lang in supportedLanguages);
|
||||
|
||||
// Filter to only valid language codes that exist in our translations
|
||||
const validLanguages = Array.from(languagesToSupport).filter(
|
||||
lang => lang in supportedLanguages
|
||||
);
|
||||
// If no valid languages were provided, keep existing configuration
|
||||
if (validLanguages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (validLanguages.length > 0) {
|
||||
i18n.options.supportedLngs = validLanguages;
|
||||
// Determine fallback: prefer validDefault if in the list, then en-GB, then first valid language
|
||||
const fallback = validDefault && validLanguages.includes(validDefault)
|
||||
? validDefault
|
||||
: validLanguages.includes('en-GB')
|
||||
? 'en-GB'
|
||||
: validLanguages[0];
|
||||
|
||||
// If current language is not in the new supported list, switch to fallback
|
||||
const currentLang = i18n.language;
|
||||
if (currentLang && !validLanguages.includes(currentLang)) {
|
||||
i18n.changeLanguage('en-GB');
|
||||
}
|
||||
i18n.options.supportedLngs = validLanguages;
|
||||
i18n.options.fallbackLng = fallback;
|
||||
|
||||
// If current language is not in the new supported list, switch to fallback
|
||||
const currentLang = normalizeLanguageCode(i18n.language || '');
|
||||
if (currentLang && !validLanguages.includes(currentLang)) {
|
||||
i18n.changeLanguage(fallback);
|
||||
} else if (validDefault && !localStorage.getItem('i18nextLng')) {
|
||||
// User has no saved preference - apply server default
|
||||
i18n.changeLanguage(validDefault);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply server default locale when user has no saved language preference
|
||||
* This respects the priority: localStorage > defaultLocale > browser detection > fallback
|
||||
*/
|
||||
function applyDefaultLocale(defaultLocale: string) {
|
||||
// Only apply if user has no saved preference
|
||||
if (!localStorage.getItem('i18nextLng')) {
|
||||
i18n.changeLanguage(defaultLocale);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -617,6 +617,41 @@
|
||||
border-color: var(--landing-drop-inner-paper-border) !important;
|
||||
}
|
||||
|
||||
/* Plan section card borders - only override in dark mode */
|
||||
[data-mantine-color-scheme="dark"] .plan-card {
|
||||
--paper-border-color: rgb(158, 158, 158) !important;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] {
|
||||
color: rgb(221, 221, 221) !important;
|
||||
}
|
||||
|
||||
/* Current plan badge - use light mode green in dark mode */
|
||||
[data-mantine-color-scheme="dark"] .current-plan-badge {
|
||||
background-color: var(--color-green-300) !important;
|
||||
}
|
||||
|
||||
/* Plan section button colors */
|
||||
.plan-button:not(:disabled):not([data-disabled]) {
|
||||
background-color: #0A8BFF !important;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .plan-button:not(:disabled):not([data-disabled]) {
|
||||
background-color: #1C598E !important;
|
||||
}
|
||||
|
||||
/* Lighter grey for disabled plan buttons */
|
||||
.plan-button:disabled,
|
||||
.plan-button[data-disabled] {
|
||||
background-color: #7e7e7e !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .plan-button:disabled,
|
||||
[data-mantine-color-scheme="dark"] .plan-button[data-disabled] {
|
||||
background-color: #6b7280 !important;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
* {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
CloseButton,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { ChangeUserPasswordRequest, User, userManagementService } from '@app/services/userManagementService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface ChangeUserPasswordModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
user: User | null;
|
||||
onSuccess: () => void;
|
||||
mailEnabled: boolean;
|
||||
}
|
||||
|
||||
function generateSecurePassword() {
|
||||
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&';
|
||||
const length = 14;
|
||||
let password = '';
|
||||
const charsetLength = charset.length;
|
||||
const uint8Array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(uint8Array);
|
||||
// To avoid modulo bias, discard values >= 256 - (256 % charsetLength)
|
||||
for (let i = 0; password.length < length; ) {
|
||||
const randomByte = uint8Array[i];
|
||||
i++;
|
||||
if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) {
|
||||
// Discard and generate a new random value
|
||||
if (i >= uint8Array.length) {
|
||||
// Exhausted the array, fill a new one
|
||||
window.crypto.getRandomValues(uint8Array);
|
||||
i = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const randomIndex = randomByte % charsetLength;
|
||||
password += charset[randomIndex];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
export default function ChangeUserPasswordModal({ opened, onClose, user, onSuccess, mailEnabled }: ChangeUserPasswordModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
generateRandom: false,
|
||||
sendEmail: false,
|
||||
includePassword: false,
|
||||
forcePasswordChange: false,
|
||||
});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const disabled = !user;
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const generated = generateSecurePassword();
|
||||
setForm((prev) => ({ ...prev, newPassword: generated, confirmPassword: generated, generateRandom: true }));
|
||||
};
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!form.newPassword) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(form.newPassword);
|
||||
alert({ alertType: 'success', title: t('workspace.people.changePassword.copiedToClipboard', 'Password copied to clipboard') });
|
||||
} catch (_error) {
|
||||
alert({ alertType: 'error', title: t('workspace.people.changePassword.copyFailed', 'Failed to copy password') });
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setForm({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
generateRandom: false,
|
||||
sendEmail: false,
|
||||
includePassword: false,
|
||||
forcePasswordChange: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (processing) return;
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (!form.generateRandom && !form.newPassword.trim()) {
|
||||
alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordRequired', 'Please enter a new password') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.generateRandom && form.newPassword !== form.confirmPassword) {
|
||||
alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ChangeUserPasswordRequest = {
|
||||
username: user.username,
|
||||
newPassword: form.newPassword, // Always send the password (frontend generates it when generateRandom is true)
|
||||
generateRandom: false, // Not needed since we're generating on frontend
|
||||
sendEmail: form.sendEmail,
|
||||
includePassword: form.includePassword,
|
||||
forcePasswordChange: form.forcePasswordChange,
|
||||
};
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await userManagementService.changeUserPassword(payload);
|
||||
alert({ alertType: 'success', title: t('workspace.people.changePassword.success', 'Password updated successfully') });
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.changePassword.error', 'Failed to update password');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setForm({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
generateRandom: false,
|
||||
sendEmail: false,
|
||||
includePassword: false,
|
||||
forcePasswordChange: false,
|
||||
});
|
||||
}
|
||||
}, [opened, user?.username]);
|
||||
|
||||
// Check if username is a valid email format
|
||||
const isValidEmail = (email: string | undefined) => {
|
||||
if (!email) return false;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const canEmail = mailEnabled && isValidEmail(user?.username);
|
||||
const passwordPreview = useMemo(() => form.newPassword && form.generateRandom ? form.newPassword : '', [form.generateRandom, form.newPassword]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
disabled={processing}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon icon="lock" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.people.changePassword.title', 'Change password')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.people.changePassword.subtitle', 'Update the password for')} <strong>{user?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label={t('workspace.people.changePassword.newPassword', 'New password')}
|
||||
placeholder={t('workspace.people.changePassword.placeholder', 'Enter a new password')}
|
||||
value={form.newPassword}
|
||||
onChange={(event) => setForm({ ...form, newPassword: event.currentTarget.value, generateRandom: false })}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('workspace.people.changePassword.confirmPassword', 'Confirm password')}
|
||||
placeholder={t('workspace.people.changePassword.confirmPlaceholder', 'Re-enter the new password')}
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) => setForm({ ...form, confirmPassword: event.currentTarget.value, generateRandom: false })}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
error={!form.generateRandom && form.confirmPassword && form.newPassword !== form.confirmPassword ? t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') : undefined}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.generateRandom', 'Generate secure password')}
|
||||
checked={form.generateRandom}
|
||||
disabled={processing || disabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setForm((prev) => ({ ...prev, generateRandom: checked }));
|
||||
if (event.currentTarget.checked) {
|
||||
handleGeneratePassword();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{passwordPreview && (
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.changePassword.generatedPreview', 'Generated password:')} <strong>{passwordPreview}</strong>
|
||||
</Text>
|
||||
<Tooltip label={t('workspace.people.changePassword.copyTooltip', 'Copy to clipboard')}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={processing}
|
||||
>
|
||||
<LocalIcon icon="content-copy" width="0.9rem" height="0.9rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.sendEmail', 'Email the user about this change')}
|
||||
checked={canEmail && form.sendEmail}
|
||||
onChange={(event) => setForm({ ...form, sendEmail: event.currentTarget.checked })}
|
||||
disabled={!canEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.includePassword', 'Include the new password in the email')}
|
||||
checked={canEmail && form.sendEmail && form.includePassword}
|
||||
onChange={(event) => setForm({ ...form, includePassword: event.currentTarget.checked })}
|
||||
disabled={!canEmail || !form.sendEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('workspace.people.changePassword.forcePasswordChange', 'Force user to change password on next login')}
|
||||
checked={form.forcePasswordChange}
|
||||
onChange={(event) => setForm({ ...form, forcePasswordChange: event.currentTarget.checked })}
|
||||
disabled={processing || disabled}
|
||||
/>
|
||||
{!canEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{mailEnabled
|
||||
? t('workspace.people.changePassword.emailUnavailable', "This user's email is not a valid email address. Notifications are disabled.")
|
||||
: t('workspace.people.changePassword.smtpDisabled', 'Email notifications require SMTP to be enabled in settings.')}
|
||||
</Text>
|
||||
)}
|
||||
{canEmail && !form.includePassword && form.sendEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.changePassword.notifyOnly', 'An email will be sent without the password, letting the user know an admin changed it.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button onClick={handleSubmit} loading={processing} fullWidth size="md" disabled={disabled} mt="md">
|
||||
{t('workspace.people.changePassword.submit', 'Update password')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+41
-23
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl } from '@mantine/core';
|
||||
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
@@ -11,6 +11,8 @@ import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
|
||||
import { supportedLanguages, toUnderscoreFormat, toUnderscoreLanguages } from '@app/i18n';
|
||||
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface GeneralSettingsData {
|
||||
ui: {
|
||||
@@ -49,6 +51,12 @@ export default function AdminGeneralSection() {
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { setIsDirty, markClean } = useUnsavedChanges();
|
||||
const languageOptions = useMemo(
|
||||
() => Object.entries(supportedLanguages)
|
||||
.map(([code, label]) => ({ value: toUnderscoreFormat(code), label: `${label} (${code})` }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[]
|
||||
);
|
||||
|
||||
// Track original settings for dirty detection
|
||||
const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState<string>('');
|
||||
@@ -73,9 +81,11 @@ export default function AdminGeneralSection() {
|
||||
apiClient.get('/api/v1/admin/settings/section/premium')
|
||||
]);
|
||||
|
||||
const ui = uiResponse.data || {};
|
||||
const system = systemResponse.data || {};
|
||||
const premium = premiumResponse.data || {};
|
||||
const ui = { ...(uiResponse.data || {}) };
|
||||
const system = { ...(systemResponse.data || {}) };
|
||||
const premium = { ...(premiumResponse.data || {}) };
|
||||
|
||||
ui.languages = Array.isArray(ui.languages) ? toUnderscoreLanguages(ui.languages) : [];
|
||||
|
||||
const result: any = {
|
||||
ui,
|
||||
@@ -152,6 +162,21 @@ export default function AdminGeneralSection() {
|
||||
}
|
||||
});
|
||||
|
||||
const selectedLanguages = useMemo(
|
||||
() => toUnderscoreLanguages(settings.ui?.languages || []),
|
||||
[settings.ui?.languages]
|
||||
);
|
||||
|
||||
// Filter default locale options based on available languages setting
|
||||
const defaultLocaleOptions = useMemo(() => {
|
||||
// If no languages are selected (empty), show all languages
|
||||
if (!selectedLanguages || selectedLanguages.length === 0) {
|
||||
return languageOptions;
|
||||
}
|
||||
// Otherwise, only show languages that are in the selected list
|
||||
return languageOptions.filter(option => selectedLanguages.includes(option.value));
|
||||
}, [selectedLanguages, languageOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch real settings if login is enabled
|
||||
if (loginEnabled) {
|
||||
@@ -369,30 +394,19 @@ export default function AdminGeneralSection() {
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
|
||||
value={settings.ui?.languages || []}
|
||||
value={selectedLanguages}
|
||||
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
|
||||
data={[
|
||||
{ value: 'de_DE', label: 'Deutsch' },
|
||||
{ value: 'es_ES', label: 'Español' },
|
||||
{ value: 'fr_FR', label: 'Français' },
|
||||
{ value: 'it_IT', label: 'Italiano' },
|
||||
{ value: 'pl_PL', label: 'Polski' },
|
||||
{ value: 'pt_BR', label: 'Português (Brasil)' },
|
||||
{ value: 'ru_RU', label: 'Русский' },
|
||||
{ value: 'zh_CN', label: '简体中文' },
|
||||
{ value: 'ja_JP', label: '日本語' },
|
||||
{ value: 'ko_KR', label: '한국어' },
|
||||
]}
|
||||
data={languageOptions}
|
||||
searchable
|
||||
clearable
|
||||
placeholder={t('admin.settings.general.languages.placeholder', 'Select languages')}
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
<Select
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.general.defaultLocale.label', 'Default Locale')}</span>
|
||||
@@ -400,9 +414,13 @@ export default function AdminGeneralSection() {
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
|
||||
value={ settings.system?.defaultLocale || ''}
|
||||
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
|
||||
placeholder="en_US"
|
||||
value={settings.system?.defaultLocale || ''}
|
||||
onChange={(value) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: value || '' } })}
|
||||
data={defaultLocaleOptions}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="en_GB"
|
||||
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
|
||||
|
||||
export default function PeopleSection() {
|
||||
const { t } = useTranslation();
|
||||
@@ -43,8 +44,11 @@ export default function PeopleSection() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [inviteModalOpened, setInviteModalOpened] = useState(false);
|
||||
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
||||
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
|
||||
const [passwordUser, setPasswordUser] = useState<User | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [mailEnabled, setMailEnabled] = useState(false);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
@@ -119,6 +123,7 @@ export default function PeopleSection() {
|
||||
premiumEnabled: adminData.premiumEnabled,
|
||||
totalUsers: adminData.totalUsers,
|
||||
});
|
||||
setMailEnabled(adminData.mailEnabled);
|
||||
} else {
|
||||
// Provide example data when login is disabled
|
||||
const exampleUsers: User[] = [
|
||||
@@ -179,6 +184,7 @@ export default function PeopleSection() {
|
||||
|
||||
setUsers(exampleUsers);
|
||||
setTeams(exampleTeams);
|
||||
setMailEnabled(false);
|
||||
|
||||
// Example license information
|
||||
setLicenseInfo({
|
||||
@@ -267,6 +273,16 @@ export default function PeopleSection() {
|
||||
setEditUserModalOpened(true);
|
||||
};
|
||||
|
||||
const openChangePasswordModal = (user: User) => {
|
||||
setPasswordUser(user);
|
||||
setChangePasswordModalOpened(true);
|
||||
};
|
||||
|
||||
const closeChangePasswordModal = () => {
|
||||
setChangePasswordModalOpened(false);
|
||||
setPasswordUser(null);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditUserModalOpened(false);
|
||||
setSelectedUser(null);
|
||||
@@ -514,7 +530,6 @@ export default function PeopleSection() {
|
||||
<Tooltip label={user.team.name} disabled={user.team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
maw={150}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
@@ -526,7 +541,7 @@ export default function PeopleSection() {
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">—</Text>
|
||||
<Text size="sm">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -549,7 +564,7 @@ export default function PeopleSection() {
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<ActionIcon variant="subtle"size="sm">
|
||||
<LocalIcon icon="info" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -557,12 +572,25 @@ export default function PeopleSection() {
|
||||
{/* Actions menu */}
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
|
||||
<ActionIcon variant="subtle" disabled={!loginEnabled}>
|
||||
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
|
||||
<Menu.Item onClick={() => openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')}</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />}
|
||||
onClick={() => openEditModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.editRole')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
@@ -591,6 +619,14 @@ export default function PeopleSection() {
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
<ChangeUserPasswordModal
|
||||
opened={changePasswordModalOpened}
|
||||
onClose={closeChangePasswordModal}
|
||||
user={passwordUser}
|
||||
onSuccess={fetchData}
|
||||
mailEnabled={mailEnabled}
|
||||
/>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<Modal
|
||||
opened={editUserModalOpened}
|
||||
|
||||
+30
@@ -22,6 +22,7 @@ import { alert } from '@app/components/toast';
|
||||
import { teamService, Team } from '@app/services/teamService';
|
||||
import { User, userManagementService } from '@app/services/userManagementService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
|
||||
|
||||
interface TeamDetailsSectionProps {
|
||||
teamId: number;
|
||||
@@ -38,6 +39,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const [userLastRequest, setUserLastRequest] = useState<Record<string, number>>({});
|
||||
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
|
||||
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
|
||||
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
|
||||
const [passwordUser, setPasswordUser] = useState<User | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
|
||||
@@ -47,6 +50,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
availableSlots: number;
|
||||
} | null>(null);
|
||||
const [mailEnabled, setMailEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamDetails();
|
||||
@@ -70,6 +74,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
setLicenseInfo({
|
||||
availableSlots: adminData.availableSlots,
|
||||
});
|
||||
setMailEnabled(adminData.mailEnabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team details:', error);
|
||||
alert({ alertType: 'error', title: t('workspace.teams.loadError', 'Failed to load team details') });
|
||||
@@ -172,6 +177,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
setChangeTeamModalOpened(true);
|
||||
};
|
||||
|
||||
const openChangePasswordModal = (user: User) => {
|
||||
setPasswordUser(user);
|
||||
setChangePasswordModalOpened(true);
|
||||
};
|
||||
|
||||
const closeChangePasswordModal = () => {
|
||||
setChangePasswordModalOpened(false);
|
||||
setPasswordUser(null);
|
||||
};
|
||||
|
||||
const handleChangeTeam = async () => {
|
||||
if (!selectedUser || !selectedTeamId) {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') });
|
||||
@@ -398,6 +413,13 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
>
|
||||
{t('workspace.teams.changeTeam.label', 'Change Team')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
{team.name !== 'Internal' && team.name !== 'Default' && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
|
||||
@@ -427,6 +449,14 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<ChangeUserPasswordModal
|
||||
opened={changePasswordModalOpened}
|
||||
onClose={closeChangePasswordModal}
|
||||
user={passwordUser}
|
||||
onSuccess={fetchTeamDetails}
|
||||
mailEnabled={mailEnabled}
|
||||
/>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={addMemberModalOpened}
|
||||
|
||||
@@ -32,6 +32,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
||||
radius="md"
|
||||
withBorder
|
||||
style={getBaseCardStyle(isCurrentTier)}
|
||||
className="plan-card"
|
||||
>
|
||||
{isCurrentTier && (
|
||||
<PricingBadge
|
||||
@@ -67,7 +68,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button variant="filled" disabled fullWidth>
|
||||
<Button variant="filled" disabled fullWidth className="plan-button">
|
||||
{isCurrentTier
|
||||
? t('plan.current', 'Current Plan')
|
||||
: t('plan.free.included', 'Included')}
|
||||
@@ -96,6 +97,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
||||
radius="md"
|
||||
withBorder
|
||||
style={getBaseCardStyle(isCurrentTier)}
|
||||
className="plan-card"
|
||||
>
|
||||
{isCurrentTier ? (
|
||||
<PricingBadge
|
||||
@@ -176,6 +178,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
||||
fullWidth
|
||||
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
|
||||
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
|
||||
className="plan-button"
|
||||
>
|
||||
{isCurrentTier
|
||||
? t('plan.manage', 'Manage')
|
||||
|
||||
@@ -17,6 +17,7 @@ export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
|
||||
variant="filled"
|
||||
size={size}
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
className={type === 'current' ? 'current-plan-badge' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
|
||||
@@ -17,6 +17,22 @@ vi.mock('react-i18next', () => ({
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock i18n module to avoid initialization
|
||||
vi.mock('@app/i18n', () => ({
|
||||
updateSupportedLanguages: vi.fn(),
|
||||
supportedLanguages: { 'en-GB': 'English' },
|
||||
rtlLanguages: [],
|
||||
default: {
|
||||
language: 'en-GB',
|
||||
changeLanguage: vi.fn(),
|
||||
options: {},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useAuth hook
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBackendProbe } from '@app/hooks/useBackendProbe';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||
import { updateSupportedLanguages } from '@app/i18n';
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
@@ -103,6 +104,11 @@ export default function Login() {
|
||||
setIsFirstTimeSetup(data.firstTimeSetup ?? false);
|
||||
setShowDefaultCredentials(data.showDefaultCredentials ?? false);
|
||||
|
||||
// Apply language configuration from server
|
||||
if (data.languages || data.defaultLocale) {
|
||||
updateSupportedLanguages(data.languages, data.defaultLocale);
|
||||
}
|
||||
|
||||
// Extract provider IDs from the providerList map
|
||||
// The keys are like "/oauth2/authorization/google" - extract the last part
|
||||
const providerIds = Object.keys(data.providerList || {})
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface AdminSettingsData {
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
mailEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -97,6 +98,15 @@ export interface InviteToken {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ChangeUserPasswordRequest {
|
||||
username: string;
|
||||
newPassword?: string;
|
||||
generateRandom?: boolean;
|
||||
sendEmail?: boolean;
|
||||
includePassword?: boolean;
|
||||
forcePasswordChange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Management Service
|
||||
* Provides functions to interact with user management backend APIs
|
||||
@@ -254,4 +264,31 @@ export const userManagementService = {
|
||||
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Change another user's password (admin only)
|
||||
*/
|
||||
async changeUserPassword(data: ChangeUserPasswordRequest): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('username', data.username);
|
||||
if (data.newPassword) {
|
||||
formData.append('newPassword', data.newPassword);
|
||||
}
|
||||
if (data.generateRandom !== undefined) {
|
||||
formData.append('generateRandom', data.generateRandom.toString());
|
||||
}
|
||||
if (data.sendEmail !== undefined) {
|
||||
formData.append('sendEmail', data.sendEmail.toString());
|
||||
}
|
||||
if (data.includePassword !== undefined) {
|
||||
formData.append('includePassword', data.includePassword.toString());
|
||||
}
|
||||
if (data.forcePasswordChange !== undefined) {
|
||||
formData.append('forcePasswordChange', data.forcePasswordChange.toString());
|
||||
}
|
||||
|
||||
await apiClient.post('/api/v1/user/admin/changePasswordForUser', formData, {
|
||||
suppressErrorToast: true, // Component will handle error display
|
||||
} as any);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user