claims) {
+ long expMillis = extractEpochMillis(claims.get("exp"));
+ if (expMillis <= 0) {
+ return false;
+ }
+
+ long now = System.currentTimeMillis();
+ if (expMillis >= now) {
+ return true;
+ }
+
+ long expiredForMillis = now - expMillis;
+ return expiredForMillis <= getRefreshGraceMillis();
+ }
+
+ private long getRefreshGraceMillis() {
+ int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes();
+ int graceMinutes =
+ configuredMinutes >= 0
+ ? configuredMinutes
+ : JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
+ return graceMinutes * JwtConstants.MILLIS_PER_MINUTE;
+ }
+
+ private long extractEpochMillis(Object claimValue) {
+ if (claimValue == null) {
+ return -1L;
+ }
+
+ if (claimValue instanceof java.util.Date date) {
+ return date.getTime();
+ }
+
+ if (claimValue instanceof Number number) {
+ long epochSeconds = number.longValue();
+ return epochSeconds * 1000L;
+ }
+
+ return -1L;
+ }
+
+ /**
+ * Generate a hash of the token for rate limiting purposes.
+ *
+ * Uses SHA-256 to avoid storing actual token values in memory.
+ *
+ * @param token the JWT token
+ * @return hex-encoded SHA-256 hash of the token
+ */
+ private String generateTokenHash(String token) {
+ try {
+ java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes =
+ digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ StringBuilder hexString = new StringBuilder();
+ for (byte b : hashBytes) {
+ String hex = Integer.toHexString(0xff & b);
+ if (hex.length() == 1) {
+ hexString.append('0');
+ }
+ hexString.append(hex);
+ }
+ return hexString.toString();
+ } catch (java.security.NoSuchAlgorithmException e) {
+ // Fallback to hashCode if SHA-256 is not available (should never happen)
+ log.warn("SHA-256 not available, using hashCode for token tracking", e);
+ return String.valueOf(token.hashCode());
+ }
+ }
+
private ResponseEntity> ensureWebAuth(User user) {
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java
index 08332dd4b9..e86857d33d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java
@@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
+import stirling.software.proprietary.security.util.DesktopClientUtils;
@Slf4j
@RequiredArgsConstructor
@@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
+ private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
- String jwt =
- jwtService.generateToken(
- authentication, Map.of("authType", AuthenticationType.OAUTH2));
+ Map claims = Map.of("authType", AuthenticationType.OAUTH2);
+
+ // Detect desktop client and issue longer-lived tokens
+ boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
+ String jwt;
+ if (isDesktopClient) {
+ // Desktop: Use configured desktop token expiry (default 30 days)
+ int desktopExpiryMinutes =
+ DesktopClientUtils.getDesktopTokenExpiryMinutes(
+ applicationProperties);
+ jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
+ log.info(
+ "Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)",
+ username,
+ desktopExpiryMinutes,
+ desktopExpiryMinutes / 1440);
+ } else {
+ // Web: Use default expiry
+ jwt = jwtService.generateToken(authentication, claims);
+ log.debug("Issued WEB OAuth2 token for user '{}'", username);
+ }
// Build context-aware redirect URL based on the original request
String redirectUrl =
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java
index 8076829ec0..f790cbac36 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java
@@ -37,6 +37,7 @@ import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
+import stirling.software.proprietary.security.util.DesktopClientUtils;
@AllArgsConstructor
@Slf4j
@@ -191,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
- String jwt =
- jwtService.generateToken(
- authentication,
- Map.of("authType", AuthenticationType.SAML2));
+ Map claims = Map.of("authType", AuthenticationType.SAML2);
+
+ // Detect desktop client and issue longer-lived tokens
+ boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
+ String jwt;
+ if (isDesktopClient) {
+ // Desktop: Use configured desktop token expiry (default 30 days)
+ int desktopExpiryMinutes =
+ DesktopClientUtils.getDesktopTokenExpiryMinutes(
+ applicationProperties);
+ jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
+ log.info(
+ "Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
+ username,
+ desktopExpiryMinutes,
+ desktopExpiryMinutes / 1440);
+ } else {
+ // Web: Use default expiry
+ jwt = jwtService.generateToken(authentication, claims);
+ log.debug("Issued WEB SAML token for user '{}'", username);
+ }
// Build context-aware redirect URL based on the original request
String redirectUrl =
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java
index 60472fef42..a551ab9070 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.time.LocalDateTime;
+import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
@@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.constants.JwtConstants;
+import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
@Service
public class JwtService implements JwtServiceInterface {
- private static final String ISSUER = "https://stirling.com";
- private static final long EXPIRATION = 43200000;
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
+ private final ApplicationProperties.Security securityProperties;
@Autowired
public JwtService(
@Qualifier("v2Enabled") boolean v2Enabled,
- KeyPersistenceServiceInterface keyPersistenceService) {
+ KeyPersistenceServiceInterface keyPersistenceService,
+ ApplicationProperties applicationProperties) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
+ this.securityProperties = applicationProperties.getSecurity();
}
@Override
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
Jwts.builder()
.claims(claims)
.subject(username)
- .issuer(ISSUER)
+ .issuer(JwtConstants.ISSUER)
.issuedAt(new Date())
- .expiration(new Date(System.currentTimeMillis() + EXPIRATION))
+ .expiration(
+ new Date(System.currentTimeMillis() + getExpirationMillis()))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
@@ -100,6 +109,40 @@ public class JwtService implements JwtServiceInterface {
}
}
+ @Override
+ public String generateToken(String username, Map claims, int expiryMinutes) {
+ try {
+ JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
+ Optional keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
+
+ if (keyPairOpt.isEmpty()) {
+ throw new RuntimeException("Unable to retrieve key pair for active key");
+ }
+
+ KeyPair keyPair = keyPairOpt.get();
+ long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
+
+ var builder =
+ Jwts.builder()
+ .claims(claims)
+ .subject(username)
+ .issuer(JwtConstants.ISSUER)
+ .issuedAt(new Date())
+ .expiration(
+ new Date(System.currentTimeMillis() + customExpirationMillis))
+ .signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
+
+ String keyId = activeKey.getKeyId();
+ if (keyId != null) {
+ builder.header().keyId(keyId);
+ }
+
+ return builder.compact();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to generate token with custom expiry", e);
+ }
+ }
+
@Override
public void validateToken(String token) throws AuthenticationFailureException {
extractAllClaims(token);
@@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface {
return extractClaim(token, Claims::getSubject);
}
+ @Override
+ public String extractUsernameAllowExpired(String token) {
+ return extractClaim(token, Claims::getSubject, true);
+ }
+
@Override
public Map extractClaims(String token) {
Claims claims = extractAllClaims(token);
return new HashMap<>(claims);
}
+ @Override
+ public Map extractClaimsAllowExpired(String token) {
+ Claims claims = extractAllClaims(token, true);
+ return new HashMap<>(claims);
+ }
+
@Override
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
@@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface {
}
private T extractClaim(String token, Function claimsResolver) {
- final Claims claims = extractAllClaims(token);
+ final Claims claims = extractAllClaims(token, false);
+ return claimsResolver.apply(claims);
+ }
+
+ private T extractClaim(
+ String token, Function claimsResolver, boolean allowExpired) {
+ final Claims claims = extractAllClaims(token, allowExpired);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
+ return extractAllClaims(token, false);
+ }
+
+ private Claims extractAllClaims(String token, boolean allowExpired) {
try {
String keyId = extractKeyId(token);
KeyPair keyPair;
@@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface {
} else {
log.debug("No key ID in token header, trying all available keys");
// Try all available keys when no keyId is present
- return tryAllKeys(token);
+ return tryAllKeys(token, allowExpired);
}
return Jwts.parser()
.verifyWith(keyPair.getPublic())
+ .clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
log.warn("Invalid token: {}", e.getMessage());
throw new AuthenticationFailureException("Invalid token", e);
} catch (ExpiredJwtException e) {
- log.warn("The token has expired: {}", e.getMessage());
+ if (allowExpired) {
+ log.debug(
+ "Extracting claims from expired token (allowed for refresh grace period): {}",
+ e.getMessage());
+ return e.getClaims();
+ }
+ log.warn("Token validation failed - token has expired: {}", e.getMessage());
throw new AuthenticationFailureException("The token has expired", e);
} catch (UnsupportedJwtException e) {
log.warn("The token is unsupported: {}", e.getMessage());
@@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface {
}
}
- private Claims tryAllKeys(String token) throws AuthenticationFailureException {
+ private Claims tryAllKeys(String token, boolean allowExpired)
+ throws AuthenticationFailureException {
// First try the active key
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
@@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface {
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
+ .clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
+ } catch (ExpiredJwtException e) {
+ if (allowExpired) {
+ log.debug(
+ "Extracting claims from expired token (allowed for refresh grace period)");
+ return e.getClaims();
+ }
+ log.warn("Token validation failed - token has expired");
+ throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException activeKeyException) {
@@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface {
verificationKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
+ .clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
+ } catch (ExpiredJwtException e) {
+ if (allowExpired) {
+ return e.getClaims();
+ }
+ throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException e) {
@@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface {
return v2Enabled;
}
+ /**
+ * Extract key ID from JWT header without validating the token.
+ *
+ * Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
+ * the header cannot be parsed or does not contain a key ID.
+ *
+ * @param token the JWT token
+ * @return the key ID, or null if not found or parsing fails
+ */
private String extractKeyId(String token) {
try {
- PublicKey signingKey =
- keyPersistenceService.decodePublicKey(
- keyPersistenceService.getActiveKey().getVerifyingKey());
+ String[] tokenParts = token.split("\\.");
+ if (tokenParts.length < 2) {
+ log.debug(
+ "Token does not have enough parts (expected at least 2, got {})",
+ tokenParts.length);
+ return null;
+ }
- String keyId =
- (String)
- Jwts.parser()
- .verifyWith(signingKey)
- .build()
- .parse(token)
- .getHeader()
- .get("kid");
- return keyId;
- } catch (Exception e) {
- log.debug("Failed to extract key ID from token header: {}", e.getMessage());
+ byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
+ Map header =
+ OBJECT_MAPPER.readValue(
+ headerBytes, new TypeReference>() {});
+ Object keyId = header.get("kid");
+ return keyId instanceof String ? (String) keyId : null;
+ } catch (IllegalArgumentException e) {
+ log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
+ return null;
+ } catch (java.io.IOException e) {
+ log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
return null;
}
}
+
+ private long getExpirationMillis() {
+ int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
+ int expiryMinutes =
+ configuredMinutes > 0
+ ? configuredMinutes
+ : JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
+ return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
+ }
+
+ private long getAllowedClockSkewSeconds() {
+ int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
+ return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
+ }
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java
index 2107f2ffd6..cded5b31fc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
*/
String generateToken(String username, Map claims);
+ /**
+ * Generate a JWT token for a specific username with custom expiry
+ *
+ * @param username the username for which to generate the token
+ * @param claims additional claims to include in the token
+ * @param expiryMinutes custom token lifetime in minutes
+ * @return JWT token as a string
+ */
+ String generateToken(String username, Map claims, int expiryMinutes);
+
/**
* Validate a JWT token
*
@@ -41,6 +51,15 @@ public interface JwtServiceInterface {
*/
String extractUsername(String token);
+ /**
+ * Extract username from JWT token while allowing expired tokens. Signature and token structure
+ * must still be valid.
+ *
+ * @param token the JWT token
+ * @return username extracted from token
+ */
+ String extractUsernameAllowExpired(String token);
+
/**
* Extract all claims from JWT token
*
@@ -49,6 +68,15 @@ public interface JwtServiceInterface {
*/
Map extractClaims(String token);
+ /**
+ * Extract all claims from JWT token while allowing expired tokens. Signature and token
+ * structure must still be valid.
+ *
+ * @param token the JWT token
+ * @return map of claims
+ */
+ Map extractClaimsAllowExpired(String token);
+
/**
* Check if token is expired
*
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java
index 48bcddac0d..d0c9f879be 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java
@@ -10,8 +10,10 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
+import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.RSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
public static final String KEY_SUFFIX = ".key";
+ public static final String PUB_KEY_SUFFIX = ".pub";
private final ApplicationProperties.Security.Jwt jwtProperties;
private final CacheManager cacheManager;
@@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
@PostConstruct
public void initializeKeystore() {
if (!isKeystoreEnabled()) {
+ log.info("JWT keystore is disabled - keys will be generated in memory");
return;
}
try {
ensurePrivateKeyDirectoryExists();
- loadKeyPair();
+ loadExistingKeysFromDisk();
} catch (Exception e) {
log.error("Failed to initialize keystore, using in-memory generation", e);
}
}
- private void loadKeyPair() {
- if (activeKey == null) {
+ /**
+ * Load all existing JWT keys from disk into memory on startup.
+ *
+ * This ensures tokens signed with previous keys remain valid after server restart. If no
+ * keys exist on disk, generates a new keypair.
+ */
+ private void loadExistingKeysFromDisk() {
+ try {
+ Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
+
+ if (!Files.exists(keyDirectory)) {
+ log.info("No existing keys found, generating new keypair");
+ generateAndStoreKeypair();
+ return;
+ }
+
+ List keyFiles;
+ try (var stream = Files.list(keyDirectory)) {
+ keyFiles =
+ stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
+ .sorted(
+ (a, b) ->
+ b.getFileName().compareTo(a.getFileName())) // Most
+ // recent
+ // first
+ .collect(Collectors.toList());
+ }
+
+ if (keyFiles.isEmpty()) {
+ log.info("No existing keys found in directory, generating new keypair");
+ generateAndStoreKeypair();
+ return;
+ }
+
+ log.info("Loading {} existing JWT keys from disk", keyFiles.size());
+ int loadedCount = 0;
+
+ for (Path keyFile : keyFiles) {
+ try {
+ String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
+
+ // Load private key first
+ PrivateKey privateKey = loadPrivateKey(keyId);
+
+ // Try to load public key, or generate it from private key if missing
+ // (migration)
+ String encodedPublicKey;
+ try {
+ encodedPublicKey = loadPublicKey(keyId);
+ } catch (IOException e) {
+ // Public key file doesn't exist - generate it from private key (migration)
+ log.info("Migrating legacy key: generating public key file for {}", keyId);
+ KeyPair keyPair = reconstructKeyPair(privateKey);
+
+ // Save the public key file
+ Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
+ encodedPublicKey = encodePublicKey(keyPair.getPublic());
+ Files.writeString(publicKeyFile, encodedPublicKey);
+ publicKeyFile.toFile().setReadable(true, true);
+ publicKeyFile.toFile().setWritable(true, true);
+ publicKeyFile.toFile().setExecutable(false, false);
+
+ log.info("Successfully migrated key: {}", keyId);
+ }
+
+ // Create verification key and add to cache
+ JwtVerificationKey verifyingKey =
+ new JwtVerificationKey(keyId, encodedPublicKey);
+ verifyingKeyCache.put(keyId, verifyingKey);
+ loadedCount++;
+
+ // Set the most recent key as active (first in sorted list)
+ if (activeKey == null) {
+ activeKey = verifyingKey;
+ log.info("Set active JWT signing key: {}", keyId);
+ } else {
+ log.debug(
+ "Loaded historical JWT key: {} (created: {})",
+ keyId,
+ verifyingKey.getCreatedAt());
+ }
+ } catch (Exception e) {
+ log.warn(
+ "Failed to load key: {}, skipping. Error: {}",
+ keyFile.getFileName(),
+ e.getMessage());
+ }
+ }
+
+ if (loadedCount == 0) {
+ log.warn("No valid keys could be loaded from disk, generating new keypair");
+ generateAndStoreKeypair();
+ } else {
+ log.info(
+ "Successfully loaded {} JWT keys, active key: {}",
+ loadedCount,
+ activeKey.getKeyId());
+ }
+
+ } catch (IOException e) {
+ log.error("Failed to load keys from disk, generating new keypair", e);
generateAndStoreKeypair();
}
}
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
KeyPair keyPair = generateRSAKeypair();
String keyId = generateKeyId();
- storePrivateKey(keyId, keyPair.getPrivate());
+ storeKeyPair(keyId, keyPair);
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
verifyingKeyCache.put(keyId, verifyingKey);
activeKey = verifyingKey;
+ log.info("Generated and stored new JWT keypair: {}", keyId);
} catch (IOException e) {
log.error("Failed to generate and store keypair", e);
}
@@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
}
}
- private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
- Path keyFile =
- Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
- String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
- Files.writeString(keyFile, encodedKey);
+ /**
+ * Store both private and public keys to disk.
+ *
+ * Private key stored as: keyId.key
+ *
+ *
Public key stored as: keyId.pub
+ */
+ private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
+ Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
- // Set read/write to only the owner
- keyFile.toFile().setReadable(true, true);
- keyFile.toFile().setWritable(true, true);
- keyFile.toFile().setExecutable(false, false);
+ // Store private key
+ Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
+ String encodedPrivateKey =
+ Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
+ Files.writeString(privateKeyFile, encodedPrivateKey);
+
+ // Set read/write to only the owner (security)
+ privateKeyFile.toFile().setReadable(true, true);
+ privateKeyFile.toFile().setWritable(true, true);
+ privateKeyFile.toFile().setExecutable(false, false);
+
+ // Store public key
+ Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
+ String encodedPublicKey =
+ Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
+ Files.writeString(publicKeyFile, encodedPublicKey);
+
+ // Public key can be more permissive but still restrict to owner
+ publicKeyFile.toFile().setReadable(true, true);
+ publicKeyFile.toFile().setWritable(true, true);
+ publicKeyFile.toFile().setExecutable(false, false);
+
+ log.debug(
+ "Stored keypair to disk: {} (private: {}, public: {})",
+ keyId,
+ privateKeyFile.getFileName(),
+ publicKeyFile.getFileName());
}
private PrivateKey loadPrivateKey(String keyId)
@@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
return keyFactory.generatePrivate(keySpec);
}
+ /**
+ * Load public key from disk.
+ *
+ * @param keyId the key identifier
+ * @return Base64-encoded public key string
+ * @throws IOException if the public key file is not found
+ */
+ private String loadPublicKey(String keyId) throws IOException {
+ Path publicKeyFile =
+ Paths.get(InstallationPathConfig.getPrivateKeyPath())
+ .resolve(keyId + PUB_KEY_SUFFIX);
+
+ if (!Files.exists(publicKeyFile)) {
+ throw new IOException("Public key not found: " + publicKeyFile);
+ }
+
+ return Files.readString(publicKeyFile).trim();
+ }
+
+ /**
+ * Reconstruct a KeyPair from a PrivateKey.
+ *
+ *
For RSA keys, derives the public key from the private key.
+ *
+ * @param privateKey the RSA private key
+ * @return reconstructed KeyPair
+ * @throws NoSuchAlgorithmException if RSA algorithm is not available
+ * @throws InvalidKeySpecException if the key specification is invalid
+ */
+ private KeyPair reconstructKeyPair(PrivateKey privateKey)
+ throws NoSuchAlgorithmException, InvalidKeySpecException {
+ // For RSA, we can derive the public key from the private key
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+
+ // Get the private key spec
+ RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
+
+ // Create public key spec from private key parameters
+ RSAPublicKeySpec publicKeySpec =
+ new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
+
+ // Generate public key
+ PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
+
+ return new KeyPair(publicKey, privateKey);
+ }
+
private String encodePublicKey(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java
new file mode 100644
index 0000000000..bb4e454290
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java
@@ -0,0 +1,124 @@
+package stirling.software.proprietary.security.service;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.constants.JwtConstants;
+import stirling.software.common.model.ApplicationProperties;
+
+/**
+ * Service to rate limit token refresh attempts within the grace period.
+ *
+ *
Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens
+ * are identified by a hash to avoid storing actual token values.
+ */
+@Service
+@Slf4j
+public class RefreshRateLimitService {
+
+ private final ApplicationProperties.Security.Jwt jwtProperties;
+
+ @Autowired
+ public RefreshRateLimitService(ApplicationProperties applicationProperties) {
+ this.jwtProperties = applicationProperties.getSecurity().getJwt();
+ }
+
+ private static class RefreshAttempt {
+ private final AtomicInteger count = new AtomicInteger(0);
+ private final Instant firstAttempt = Instant.now();
+
+ int incrementAndGet() {
+ return count.incrementAndGet();
+ }
+
+ Instant getFirstAttempt() {
+ return firstAttempt;
+ }
+
+ int getCount() {
+ return count.get();
+ }
+ }
+
+ private final Map attempts = new ConcurrentHashMap<>();
+
+ /**
+ * Check if a refresh attempt is allowed for the given token.
+ *
+ * @param tokenHash hash of the token attempting refresh
+ * @param graceWindowMillis the configured grace window in milliseconds
+ * @return true if refresh is allowed, false if rate limit exceeded
+ */
+ public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) {
+ RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt());
+
+ int attemptCount = attempt.incrementAndGet();
+
+ if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) {
+ log.warn(
+ "Refresh rate limit exceeded for token (attempt {}). Token hash: {}",
+ attemptCount,
+ tokenHash.substring(0, Math.min(8, tokenHash.length())));
+ return false;
+ }
+
+ // Clean up if outside grace window
+ Instant cutoff = Instant.now().minusMillis(graceWindowMillis);
+ if (attempt.getFirstAttempt().isBefore(cutoff)) {
+ attempts.remove(tokenHash);
+ }
+
+ return true;
+ }
+
+ /**
+ * Remove tracking for a token after successful refresh.
+ *
+ * @param tokenHash hash of the refreshed token
+ */
+ public void clearRefreshAttempts(String tokenHash) {
+ attempts.remove(tokenHash);
+ }
+
+ /** Clean up expired tracking entries every 5 minutes. */
+ @Scheduled(fixedRate = 300000)
+ public void cleanupExpiredEntries() {
+ // Use configured grace period with same normalization as runtime checks
+ int configuredMinutes = jwtProperties.getRefreshGraceMinutes();
+ int graceMinutes =
+ configuredMinutes >= 0
+ ? configuredMinutes
+ : JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
+ Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L);
+ int removed =
+ attempts.entrySet().stream()
+ .filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff))
+ .mapToInt(
+ entry -> {
+ attempts.remove(entry.getKey());
+ return 1;
+ })
+ .sum();
+
+ if (removed > 0) {
+ log.debug("Cleaned up {} expired refresh tracking entries", removed);
+ }
+ }
+
+ /** Get current tracking statistics for monitoring. */
+ public Map getStats() {
+ return Map.of(
+ "tracked_tokens",
+ attempts.size(),
+ "max_attempts_allowed",
+ JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java
new file mode 100644
index 0000000000..3ccfd8e773
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java
@@ -0,0 +1,82 @@
+package stirling.software.proprietary.security.util;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.constants.JwtConstants;
+import stirling.software.common.model.ApplicationProperties;
+
+/**
+ * Utility class for detecting desktop clients and determining appropriate token expiry times.
+ *
+ * Desktop clients (Tauri, Electron) receive longer-lived tokens because:
+ *
+ *
+ * They run on personal devices (not shared computers)
+ * Tokens stored in OS-level encrypted keychain (not browser localStorage)
+ * Better UX (users expect desktop apps to stay logged in)
+ *
+ */
+@Slf4j
+public class DesktopClientUtils {
+
+ private DesktopClientUtils() {
+ // Utility class - prevent instantiation
+ }
+
+ /**
+ * Detect if the request is from a desktop client (Tauri app).
+ *
+ * @param request the HTTP request
+ * @return true if desktop client, false if web browser
+ */
+ public static boolean isDesktopClient(HttpServletRequest request) {
+ String userAgent = request.getHeader("User-Agent");
+
+ if (userAgent == null) {
+ return false;
+ }
+
+ // Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent
+ // Also check for common desktop app identifiers
+ String userAgentLower = userAgent.toLowerCase();
+ boolean hasTauri = userAgentLower.contains("tauri");
+ boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop");
+ boolean hasElectron = userAgentLower.contains("electron");
+ boolean isDesktop = hasTauri || hasStirling || hasElectron;
+
+ log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent);
+
+ return isDesktop;
+ }
+
+ /**
+ * Get the configured desktop token expiry time in minutes.
+ *
+ * @param applicationProperties the application properties
+ * @return desktop token expiry in minutes (defaults to 30 days if not configured)
+ */
+ public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) {
+ int configuredMinutes =
+ applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes();
+ // If not configured or invalid, default to 30 days (43200 minutes)
+ return configuredMinutes > 0
+ ? configuredMinutes
+ : JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES;
+ }
+
+ /**
+ * Get the configured web token expiry time in minutes.
+ *
+ * @param applicationProperties the application properties
+ * @return web token expiry in minutes
+ */
+ public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) {
+ int configuredMinutes =
+ applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes();
+ return configuredMinutes > 0
+ ? configuredMinutes
+ : JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java
index 86bcf50e8c..fd1a99e7b1 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java
@@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import java.util.Date;
+import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@@ -36,6 +38,7 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
+import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@@ -53,11 +56,17 @@ class AuthControllerLoginTest {
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
+ @Mock private RefreshRateLimitService refreshRateLimitService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
+ securityProperties.getJwt().setTokenExpiryMinutes(60);
+ securityProperties.getJwt().setRefreshGraceMinutes(5);
+
+ ApplicationProperties applicationProperties = new ApplicationProperties();
+ applicationProperties.setSecurity(securityProperties);
AuthController controller =
new AuthController(
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
loginAttemptService,
mfaService,
totpService,
- securityProperties);
+ refreshRateLimitService,
+ securityProperties,
+ applicationProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
- when(jwtService.extractUsername("old")).thenReturn("user@example.com");
+ Map claims = new HashMap<>();
+ claims.put("sub", "user@example.com");
+ claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
+ when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
+ // Rate limiting is not checked for valid tokens, so no stub needed
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.user").exists())
.andExpect(jsonPath("$.session.access_token").value("new-token"))
- .andExpect(jsonPath("$.session.expires_in").value(3600));
+ .andExpect(
+ jsonPath("$.session.expires_in")
+ .value(3600)); // 60 minutes * 60 = 3600 seconds
+
+ // clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
+ // period
+ }
+
+ @Test
+ void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
+ when(jwtService.extractToken(any())).thenReturn("old");
+ Map claims = new HashMap<>();
+ claims.put("sub", "user@example.com");
+ claims.put(
+ "exp",
+ new Date(
+ System.currentTimeMillis()
+ - (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace
+ when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
+
+ mockMvc.perform(post("/api/v1/auth/refresh"))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.error").value("Token refresh failed"));
+
+ verify(userDetailsService, never()).loadUserByUsername(any());
+ verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class));
+ }
+
+ @Test
+ void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
+ User user = buildUser();
+ when(jwtService.extractToken(any())).thenReturn("old");
+ Map claims = new HashMap<>();
+ claims.put("sub", "user@example.com");
+ claims.put(
+ "exp",
+ new Date(
+ System.currentTimeMillis()
+ - 60_000)); // 1 minute ago, within 5 minute grace
+ when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
+ when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true);
+ when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
+ when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
+ .thenReturn("new-token");
+
+ mockMvc.perform(post("/api/v1/auth/refresh"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.session.access_token").value("new-token"));
+
+ // clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
+ // period
+ }
+
+ @Test
+ void refreshRejectsWhenRateLimitExceeded() throws Exception {
+ when(jwtService.extractToken(any())).thenReturn("old");
+ Map claims = new HashMap<>();
+ claims.put("sub", "user@example.com");
+ claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago
+ when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
+ when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false);
+
+ mockMvc.perform(post("/api/v1/auth/refresh"))
+ .andExpect(status().isTooManyRequests())
+ .andExpect(jsonPath("$.error").value("Too many refresh attempts"))
+ .andExpect(jsonPath("$.max_attempts").exists());
+
+ verify(userDetailsService, never()).loadUserByUsername(any());
+ verify(refreshRateLimitService, never()).clearRefreshAttempts(any());
}
@Test
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java
index 376d0b4ed7..b61883949e 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java
@@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
oauth2Props.setAutoCreateUser(true);
oauth2Props.setBlockRegistration(false);
+ ApplicationProperties applicationProperties = new ApplicationProperties();
+ ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
+ securityProperties.setOauth2(oauth2Props);
+ applicationProperties.setSecurity(securityProperties);
+
CustomOAuth2AuthenticationSuccessHandler handler =
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
- licenseSettingsService);
+ licenseSettingsService,
+ applicationProperties);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java
index e8a6d60453..787aa57e3f 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
@@ -64,7 +65,8 @@ class JwtServiceTest {
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
- jwtService = new JwtService(true, keystoreService);
+ ApplicationProperties applicationProperties = new ApplicationProperties();
+ jwtService = new JwtService(true, keystoreService, applicationProperties);
}
@Test
@@ -73,8 +75,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -94,8 +94,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -114,8 +112,6 @@ class JwtServiceTest {
void testValidateTokenSuccess() throws Exception {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn("testuser");
@@ -179,8 +175,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(user);
when(user.getUsername()).thenReturn(username);
@@ -207,8 +201,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -281,8 +273,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -307,8 +297,6 @@ class JwtServiceTest {
// First, generate a token successfully
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
- when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
- .thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
diff --git a/build.gradle b/build.gradle
index fdf864b862..fd706cb6ca 100644
--- a/build.gradle
+++ b/build.gradle
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
- version = '2.4.5'
+ version = '2.5.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 33dee79505..16660e79d4 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -1236,9 +1236,21 @@ label = "Enable Key Cleanup"
description = "Automatically rotate JWT signing keys periodically"
label = "Enable Key Rotation"
-[admin.settings.security.jwt.keyRetentionDays]
-description = "Number of days to retain old JWT keys for verification"
-label = "Key Retention Days"
+[admin.settings.security.jwt.tokenExpiryMinutes]
+description = "Access token lifetime in minutes for web clients (default: 1440 = 24 hours)"
+label = "Web Token Expiry (minutes)"
+
+[admin.settings.security.jwt.desktopTokenExpiryMinutes]
+description = "Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)"
+label = "Desktop Token Expiry (minutes)"
+
+[admin.settings.security.jwt.allowedClockSkewSeconds]
+description = "Tolerance for client/server time drift during token validation (default: 60 seconds)"
+label = "Clock Skew Tolerance (seconds)"
+
+[admin.settings.security.jwt.refreshGraceMinutes]
+description = "Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)"
+label = "Refresh Grace Period (minutes)"
[admin.settings.security.jwt.persistence]
description = "Store JWT keys persistently to survive server restarts"
diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json
index 0da39ba5a6..7c5b3d957f 100644
--- a/frontend/src-tauri/tauri.conf.json
+++ b/frontend/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
- "version": "2.4.6",
+ "version": "2.5.0",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts
index 7bafd20005..9a922911b8 100644
--- a/frontend/src/core/testing/serverExperienceSimulations.ts
+++ b/frontend/src/core/testing/serverExperienceSimulations.ts
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
- appVersion: '2.4.6',
+ appVersion: '2.5.0',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx
index bf53cd05bf..d709315a31 100644
--- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx
+++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx
@@ -227,6 +227,10 @@ export const ServerSelection: React.FC = ({ onSelect, load
disabled={testing || loading}
onClick={() => {
setCustomUrl(serverUrl);
+ // Auto-submit the form after setting the URL
+ setTimeout(() => {
+ handleSubmit(new Event('submit') as any);
+ }, 0);
}}
>
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
diff --git a/frontend/src/desktop/extensions/authSessionCleanup.ts b/frontend/src/desktop/extensions/authSessionCleanup.ts
index a4d8eb6eab..916fceec01 100644
--- a/frontend/src/desktop/extensions/authSessionCleanup.ts
+++ b/frontend/src/desktop/extensions/authSessionCleanup.ts
@@ -13,7 +13,17 @@ export async function clearPlatformAuthAfterSignOut(): Promise {
export async function clearPlatformAuthOnLoginInit(): Promise {
try {
- await authService.localClearAuth();
+ // Only clear if there's NO token in storage
+ // If token exists, user just logged in and we should keep it
+ const token = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
+ console.log('[AuthCleanup] Login init check - token exists:', !!token, 'length:', token?.length || 0);
+
+ if (!token) {
+ console.log('[AuthCleanup] No token found on login init, clearing stale auth data');
+ await authService.localClearAuth();
+ } else {
+ console.log('[AuthCleanup] Token present on login init (length:', token.length, '), skipping cleanup (fresh login)');
+ }
} catch (err) {
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
}
diff --git a/frontend/src/desktop/extensions/platformSessionBridge.ts b/frontend/src/desktop/extensions/platformSessionBridge.ts
new file mode 100644
index 0000000000..2b9a5f1f76
--- /dev/null
+++ b/frontend/src/desktop/extensions/platformSessionBridge.ts
@@ -0,0 +1,62 @@
+import { STIRLING_SAAS_URL } from '@app/constants/connection';
+import { connectionModeService } from '@app/services/connectionModeService';
+import { authService } from '@app/services/authService';
+import type { PlatformSessionUser } from '@proprietary/extensions/platformSessionBridge';
+
+export async function isDesktopSaaSAuthMode(): Promise {
+ try {
+ const mode = await connectionModeService.getCurrentMode();
+ // Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
+ // This skips redundant backend validation in springAuthClient since desktop authService
+ // already manages the token lifecycle
+ return mode === 'saas' || mode === 'selfhosted';
+ } catch {
+ return false;
+ }
+}
+
+export async function getPlatformSessionUser(): Promise {
+ try {
+ const userInfo = await authService.getUserInfo();
+ if (!userInfo) {
+ return null;
+ }
+ return {
+ username: userInfo.username,
+ email: userInfo.email,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function refreshPlatformSession(): Promise {
+ try {
+ const mode = await connectionModeService.getCurrentMode();
+ if (mode === 'saas') {
+ return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
+ } else if (mode === 'selfhosted') {
+ const serverConfig = await connectionModeService.getServerConfig();
+ if (!serverConfig) {
+ return false;
+ }
+ return await authService.refreshToken(serverConfig.url);
+ }
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Save token to platform-specific secure storage (Tauri store + localStorage)
+ * Called after token refresh to ensure token is synced across all storage locations
+ */
+export async function savePlatformToken(token: string): Promise {
+ try {
+ await authService.saveToken(token);
+ } catch (error) {
+ console.error('[PlatformBridge] Failed to save token:', error);
+ throw error;
+ }
+}
diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts
index eae32c3833..0a5037b1f3 100644
--- a/frontend/src/desktop/services/apiClientSetup.ts
+++ b/frontend/src/desktop/services/apiClientSetup.ts
@@ -16,6 +16,7 @@ let lastBackendToast = 0;
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
+ skipAuthRedirect?: boolean;
_retry?: boolean;
}
@@ -55,7 +56,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Self-hosted mode: enable credentials for session management
extendedConfig.withCredentials = true;
+ // If another request is already refreshing, wait before attaching token.
+ await authService.awaitRefreshIfInProgress();
const token = await authService.getAuthToken();
+
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
} else {
@@ -104,9 +108,16 @@ export function setupApiInterceptors(client: AxiosInstance): void {
},
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
+ const requestUrl = String(originalRequest?.url || '');
+ const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
+ // `/auth/me` is used as a probe by session bootstrap; refreshing here can
+ // create recursion (refresh -> save token -> jwt-available -> /auth/me).
+ if (isAuthProbeRequest) {
+ return Promise.reject(error);
+ }
if (typeof window !== 'undefined') {
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
}
diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts
index b3f3da9114..3095b6cb22 100644
--- a/frontend/src/desktop/services/authService.ts
+++ b/frontend/src/desktop/services/authService.ts
@@ -39,6 +39,7 @@ export class AuthService {
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private cachedToken: string | null = null;
+ private lastTokenSaveTime: number = 0;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
private refreshPromise: Promise | null = null;
@@ -52,52 +53,51 @@ export class AuthService {
/**
* Save token to all storage locations and notify listeners
*/
- private async saveTokenEverywhere(token: string, refreshToken?: string | null): Promise {
+ private async saveTokenEverywhere(
+ token: string,
+ refreshToken?: string | null,
+ emitJwtAvailable = true
+ ): Promise {
// Validate token before caching
if (!token || token.trim().length === 0) {
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
throw new Error('Invalid token');
}
- console.log(`[Desktop AuthService] Saving token (length: ${token.length})`);
-
// Save access token to Tauri secure store (primary)
try {
await invoke('save_auth_token', { token });
- console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
} catch (error) {
- console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error);
+ console.error('[Desktop AuthService] Failed to save token to Tauri store:', error);
// Don't throw - we can still use localStorage
}
// Sync to localStorage for web layer (fallback)
try {
localStorage.setItem('stirling_jwt', token);
- console.log('[Desktop AuthService] ✅ Token saved to localStorage');
} catch (error) {
- console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error);
+ console.error('[Desktop AuthService] Failed to save token to localStorage:', error);
}
// Cache the valid token in memory
this.cachedToken = token;
- console.log('[Desktop AuthService] ✅ Token cached in memory');
+ this.lastTokenSaveTime = Date.now();
// Save refresh token if provided (keyring with Tauri Store fallback)
if (refreshToken) {
- console.log('[Desktop AuthService] Saving refresh token to secure storage...');
try {
await invoke('save_refresh_token', { token: refreshToken });
- console.log('[Desktop AuthService] ✅ Refresh token saved to secure storage');
// Only remove from localStorage after successful save
localStorage.removeItem('stirling_refresh_token');
} catch (error) {
- console.error('[Desktop AuthService] ❌ Failed to save refresh token:', error);
+ console.error('[Desktop AuthService] Failed to save refresh token:', error);
}
}
- // Notify other parts of the system
- window.dispatchEvent(new CustomEvent('jwt-available'));
- console.log('[Desktop AuthService] Dispatched jwt-available event');
+ if (emitJwtAvailable) {
+ // Notify other parts of the system when a brand-new auth session is established.
+ window.dispatchEvent(new CustomEvent('jwt-available'));
+ }
}
/**
@@ -108,37 +108,21 @@ export class AuthService {
try {
const token = await invoke('get_auth_token');
if (token) {
- console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
return token;
}
-
- console.log('[Desktop AuthService] ℹ️ No token in Tauri store, checking localStorage...');
} catch (error) {
- console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
+ console.error('[Desktop AuthService] Failed to read from Tauri store:', error);
}
// Fallback to localStorage
- const localStorageToken = localStorage.getItem('stirling_jwt');
- if (localStorageToken) {
- console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
- } else {
- console.log('[Desktop AuthService] ❌ No token found in any storage');
- }
-
- return localStorageToken;
+ return localStorage.getItem('stirling_jwt');
}
/**
* Get refresh token from secure storage (keyring or Tauri Store fallback)
*/
private async getRefreshToken(): Promise {
- const token = await invoke('get_refresh_token');
- if (token) {
- console.log('[Desktop AuthService] ✅ Refresh token retrieved from secure storage');
- } else {
- console.log('[Desktop AuthService] No refresh token in secure storage');
- }
- return token;
+ return await invoke('get_refresh_token');
}
/**
@@ -147,19 +131,16 @@ export class AuthService {
private async clearTokenEverywhere(): Promise {
// Invalidate cache
this.cachedToken = null;
- console.log('[Desktop AuthService] Cache invalidated');
// Best effort: clear Tauri keyring (both access and refresh tokens)
try {
await invoke('clear_auth_token');
- console.log('[Desktop AuthService] Cleared Tauri keyring access token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear Tauri keyring access token', error);
}
try {
await invoke('clear_refresh_token');
- console.log('[Desktop AuthService] Cleared Tauri keyring refresh token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear Tauri keyring refresh token', error);
}
@@ -168,7 +149,6 @@ export class AuthService {
try {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem('stirling_refresh_token');
- console.log('[Desktop AuthService] Cleared localStorage tokens');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear localStorage tokens', error);
}
@@ -268,24 +248,17 @@ export class AuthService {
}
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise {
- console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
- console.log(`[Desktop AuthService] Username: ${username}`);
-
try {
// Validate SaaS configuration if connecting to SaaS
if (serverUrl === STIRLING_SAAS_URL) {
if (!STIRLING_SAAS_URL) {
- console.error('[Desktop AuthService] ❌ VITE_SAAS_SERVER_URL is not configured');
throw new Error('VITE_SAAS_SERVER_URL is not configured');
}
if (!SUPABASE_KEY) {
- console.error('[Desktop AuthService] ❌ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
}
- console.log('[Desktop AuthService] Invoking Rust login command...');
-
// Call Rust login command (bypasses CORS)
const response = await invoke('login', {
serverUrl,
@@ -298,26 +271,19 @@ export class AuthService {
const { token, username: returnedUsername, email } = response;
- console.log('[Desktop AuthService] ✅ Login response received');
- console.log(`[Desktop AuthService] Username from response: ${returnedUsername || username}`);
-
// Save token to all storage locations
try {
- console.log('[Desktop AuthService] Saving token to storage...');
await this.saveTokenEverywhere(token);
- console.log('[Desktop AuthService] ✅ Token saved successfully');
} catch (error) {
- console.error('[Desktop AuthService] ❌ Failed to save token:', error);
+ console.error('[Desktop AuthService] Failed to save token:', error);
throw new Error('Failed to save authentication token');
}
// Save user info to store
- console.log('[Desktop AuthService] Saving user info...');
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
- console.log('[Desktop AuthService] ✅ User info saved');
const userInfo: UserInfo = {
username: returnedUsername || username,
@@ -326,10 +292,9 @@ export class AuthService {
this.setAuthStatus('authenticated', userInfo);
- console.log('[Desktop AuthService] ✅ Login completed successfully');
return userInfo;
} catch (error) {
- console.error('[Desktop AuthService] ❌ Login failed:', error);
+ console.error('[Desktop AuthService] Login failed:', error);
// Provide more detailed error messages based on the error type
if (error instanceof Error || typeof error === 'string') {
@@ -338,55 +303,46 @@ export class AuthService {
if (errMsg.includes('mfa_required')) {
this.setAuthStatus('unauthenticated', null);
- console.error('[Desktop AuthService] Two-factor authentication required');
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
}
if (errMsg.includes('invalid_mfa_code')) {
this.setAuthStatus('unauthenticated', null);
- console.error('[Desktop AuthService] Invalid two-factor code provided');
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
}
// Authentication errors
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
- console.error('[Desktop AuthService] Authentication failed - invalid credentials');
this.setAuthStatus('unauthenticated', null);
throw new Error('Invalid username or password. Please check your credentials and try again.');
}
// Server not found or unreachable
else if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
- console.error('[Desktop AuthService] Server connection refused');
this.setAuthStatus('unauthenticated', null);
throw new Error('Cannot connect to server. Please check the server URL and ensure the server is running.');
}
// Timeout
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
- console.error('[Desktop AuthService] Login request timed out');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login request timed out. Please check your network connection and try again.');
}
// DNS failure
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
- console.error('[Desktop AuthService] DNS resolution failed');
this.setAuthStatus('unauthenticated', null);
throw new Error('Cannot resolve server address. Please check the server URL is correct.');
}
// SSL/TLS errors
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
- console.error('[Desktop AuthService] SSL/TLS error');
this.setAuthStatus('unauthenticated', null);
throw new Error('SSL/TLS certificate error. Server may have an invalid or self-signed certificate.');
}
// 404 - endpoint not found
else if (errMsg.includes('404') || errMsg.includes('not found')) {
- console.error('[Desktop AuthService] Login endpoint not found');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.');
}
// 403 - security disabled
else if (errMsg.includes('403') || errMsg.includes('forbidden')) {
- console.error('[Desktop AuthService] Login disabled on server');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).');
}
@@ -398,10 +354,16 @@ export class AuthService {
}
}
+ /**
+ * Public method to save token to all storage locations
+ * Called by springAuthClient after token refresh to sync Tauri store
+ */
+ async saveToken(token: string): Promise {
+ await this.saveTokenEverywhere(token, undefined, false);
+ }
+
async logout(): Promise {
try {
- console.log('Logging out');
-
// Best-effort backend logout so any server-side session/cookies are cleared
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
@@ -444,8 +406,6 @@ export class AuthService {
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
-
- console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
@@ -457,21 +417,31 @@ export class AuthService {
async getAuthToken(): Promise {
try {
- // Return cached token if available
+ // Check cached token validity before returning
if (this.cachedToken) {
- console.debug('[Desktop AuthService] ✅ Returning cached token');
- return this.cachedToken;
+ // Use minimal leeway (5s) for cache validation to avoid excessive invalidation
+ // Health checks run every 5s, so 30s leeway would cause 5-6 unnecessary cache clears
+ // The 30s leeway is used elsewhere for proactive refresh before user operations
+ if (this.isTokenExpiringSoon(this.cachedToken, 5)) {
+ console.warn('[Desktop AuthService] ⚠️ Cached token is expired or expiring soon, invalidating cache');
+ this.cachedToken = null;
+ // Fall through to fetch from storage
+ } else {
+ console.debug('[Desktop AuthService] ✅ Returning cached token');
+ return this.cachedToken;
+ }
}
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
const token = await this.getTokenFromAnySource();
- // Cache the token if valid
+ // Cache token if found (backend will validate expiry)
if (token && token.trim().length > 0) {
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
+ return token;
}
- return token;
+ return null;
} catch (error) {
console.error('[Desktop AuthService] Failed to get auth token:', error);
return null;
@@ -505,6 +475,59 @@ export class AuthService {
}
}
+ async awaitRefreshIfInProgress(): Promise {
+ if (!this.refreshPromise) {
+ return false;
+ }
+ try {
+ console.debug('[Desktop AuthService] Waiting for in-flight refresh to complete');
+ return await this.refreshPromise;
+ } catch (error) {
+ console.warn('[Desktop AuthService] In-flight refresh failed while waiting', error);
+ return false;
+ }
+ }
+
+ isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
+ try {
+ const parts = token.split('.');
+ if (parts.length < 2) {
+ console.warn('[Desktop AuthService] Token malformed - less than 2 parts');
+ return true;
+ }
+
+ const base64Url = parts[1];
+ const base64 = base64Url
+ .replace(/-/g, '+')
+ .replace(/_/g, '/')
+ .padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
+ const payload = JSON.parse(atob(base64));
+ const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
+
+ if (!expSeconds) {
+ console.warn('[Desktop AuthService] Token has no exp claim');
+ return true;
+ }
+
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const nowWithLeeway = nowSeconds + Math.max(0, leewaySeconds);
+ const timeUntilExpiry = expSeconds - nowSeconds;
+ const isExpiring = expSeconds <= nowWithLeeway;
+
+ console.debug('[Desktop AuthService] Token expiry check:', {
+ expiresIn: timeUntilExpiry + 's',
+ leeway: leewaySeconds + 's',
+ isExpiring
+ });
+
+ return isExpiring;
+ } catch (err) {
+ // If parsing fails, treat token as unsafe/stale and force refresh path.
+ console.warn('[Desktop AuthService] Token parsing failed:', err);
+ return true;
+ }
+ }
+
async refreshToken(serverUrl: string): Promise {
// Prevent concurrent refresh attempts - reuse in-flight refresh
if (this.refreshPromise) {
@@ -542,10 +565,20 @@ export class AuthService {
}
);
- const { token } = response.data;
+ const token =
+ response.data?.session?.access_token ??
+ response.data?.access_token ??
+ response.data?.token;
+
+ if (!token) {
+ console.error('[Desktop AuthService] Refresh response missing token payload');
+ this.setAuthStatus('unauthenticated', null);
+ await this.logout();
+ return false;
+ }
// Save token to all storage locations
- await this.saveTokenEverywhere(token);
+ await this.saveTokenEverywhere(token, undefined, false);
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
@@ -607,7 +640,7 @@ export class AuthService {
const { access_token, refresh_token: newRefreshToken } = response.data;
// Save new tokens
- await this.saveTokenEverywhere(access_token, newRefreshToken);
+ await this.saveTokenEverywhere(access_token, newRefreshToken, false);
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
@@ -630,16 +663,28 @@ export class AuthService {
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
const path = typeof window !== 'undefined' ? window.location.pathname : '';
if (path.startsWith('/login') || path.startsWith('/setup')) {
- console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
- // Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
- await this.clearTokenEverywhere().catch(() => {});
- try {
- await invoke('clear_user_info');
- } catch (err) {
- console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
+ // Check if token exists in storage (user just logged in via web flow)
+ const tokenInStorage = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
+ if (tokenInStorage) {
+ console.log('[Desktop AuthService] On login/setup path with token present - skipping validation');
+ console.log('[Desktop AuthService] Login flow will handle authentication state');
+ // Return early to avoid clearing partial state during login completion
+ // The login completion handler (completeSelfHostedSession) will:
+ // 1. Fetch and save user info
+ // 2. Set auth status to authenticated
+ return;
+ } else {
+ console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
+ // Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
+ await this.clearTokenEverywhere().catch(() => {});
+ try {
+ await invoke('clear_user_info');
+ } catch (err) {
+ console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
+ }
+ this.setAuthStatus('unauthenticated', null);
+ return;
}
- this.setAuthStatus('unauthenticated', null);
- return;
}
const token = await this.getAuthToken();
diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts
index 7cd5c09426..ceb39c4d8f 100644
--- a/frontend/src/desktop/services/tauriBackendService.ts
+++ b/frontend/src/desktop/services/tauriBackendService.ts
@@ -117,20 +117,15 @@ export class TauriBackendService {
}
/**
- * Get auth token from any available source (localStorage or Tauri store)
+ * Get auth token with expiry validation
+ * Delegates to authService which handles caching and expiry checking
*/
private async getAuthToken(): Promise {
- // Check localStorage first (web layer token)
- const localStorageToken = localStorage.getItem('stirling_jwt');
- if (localStorageToken) {
- return localStorageToken;
- }
-
- // Fallback to Tauri store
try {
- return await invoke('get_auth_token');
- } catch {
- console.debug('[TauriBackendService] No auth token available');
+ const { authService } = await import('./authService');
+ return await authService.getAuthToken();
+ } catch (error) {
+ console.debug('[TauriBackendService] Failed to get auth token:', error);
return null;
}
}
diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts
index 0a874cd4e6..cae070373a 100644
--- a/frontend/src/proprietary/auth/springAuthClient.test.ts
+++ b/frontend/src/proprietary/auth/springAuthClient.test.ts
@@ -52,6 +52,7 @@ describe('SpringAuthClient', () => {
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
+ skipAuthRedirect: true,
});
expect(result.data.session).toBeTruthy();
expect(result.data.session?.user).toEqual(mockUser);
@@ -309,14 +310,10 @@ describe('SpringAuthClient', () => {
},
} as any);
- const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
-
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
- expect(dispatchEventSpy).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'jwt-available' })
- );
+ // Note: refreshSession does not dispatch jwt-available event, only notifies listeners
expect(result.data.session?.access_token).toBe(newToken);
expect(result.error).toBeNull();
});
diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts
index 7f70392184..373df79c76 100644
--- a/frontend/src/proprietary/auth/springAuthClient.ts
+++ b/frontend/src/proprietary/auth/springAuthClient.ts
@@ -13,8 +13,29 @@ import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
import { resetOAuthState } from '@app/auth/oauthStorage';
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
+import {
+ getPlatformSessionUser,
+ isDesktopSaaSAuthMode,
+ refreshPlatformSession,
+ savePlatformToken,
+} from '@app/extensions/platformSessionBridge';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
+function getHttpStatus(error: unknown): number | undefined {
+ if (error instanceof AxiosError) {
+ return error.response?.status;
+ }
+
+ if (error && typeof error === 'object' && 'response' in error) {
+ const response = (error as { response?: { status?: unknown } }).response;
+ if (response && typeof response.status === 'number') {
+ return response.status;
+ }
+ }
+
+ return undefined;
+}
+
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AxiosError) {
@@ -98,14 +119,100 @@ type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => v
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
- private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
- private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
+
+ // Adaptive intervals - calculated based on actual JWT token lifetime
+ // Defaults for initial startup (will be recalculated on first token)
+ private sessionCheckIntervalMs = 10000; // 10 seconds default
+ private tokenRefreshThresholdMs = 30000; // 30 seconds default
+
+ private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
+ /**
+ * Calculate optimal check interval and refresh threshold based on token lifetime.
+ * - Check interval: token lifetime / 6 (check 6 times during token life)
+ * - Refresh threshold: token lifetime / 4 (refresh when 25% remaining)
+ * - Applies min/max bounds for sanity
+ */
+ private calculateAdaptiveIntervals(token: string): void {
+ try {
+ const payload = this.decodeJwtPayload(token);
+ if (!payload) {
+ console.warn('[SpringAuth] Cannot decode token for adaptive intervals, using defaults');
+ return;
+ }
+
+ const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
+ const iatSeconds = typeof payload?.iat === 'number' ? payload.iat : 0;
+
+ if (expSeconds <= 0 || iatSeconds <= 0) {
+ console.warn('[SpringAuth] Token missing exp/iat claims, using default intervals');
+ return;
+ }
+
+ const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000;
+
+ // Check interval: check 6 times during token lifetime
+ // Min: 5 seconds (for very short tokens)
+ // Max: 60 seconds (don't check too infrequently)
+ this.sessionCheckIntervalMs = Math.max(5000, Math.min(60000, tokenLifetimeMs / 6));
+
+ // Refresh threshold: refresh when 25% of lifetime remaining
+ // Min: 30 seconds (give buffer for refresh to complete)
+ // Max: 5 minutes (don't wait too long for long-lived tokens)
+ this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4));
+
+ console.log('[SpringAuth] 📊 Adaptive intervals calculated:', {
+ tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + 's',
+ checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + 's',
+ refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + 's',
+ });
+
+ // Restart monitoring with new interval
+ this.restartSessionMonitoring();
+ } catch (error) {
+ console.warn('[SpringAuth] Failed to calculate adaptive intervals:', error);
+ }
+ }
+
+ private decodeJwtPayload(token: string): Record | null {
+ const parts = token.split('.');
+ if (parts.length < 2) {
+ return null;
+ }
+
+ const base64Url = parts[1];
+ const base64 = base64Url
+ .replace(/-/g, '+')
+ .replace(/_/g, '/')
+ .padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
+
+ return JSON.parse(atob(base64));
+ }
+
+ private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } {
+ try {
+ const payload = this.decodeJwtPayload(token);
+ if (!payload) {
+ throw new Error('Token payload missing');
+ }
+
+ const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
+ const expiresAt = expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
+ const expiresIn = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
+
+ return { expiresIn, expiresAt };
+ } catch {
+ // Fallback for non-JWT or malformed tokens.
+ const expiresAt = Date.now() + 3600 * 1000;
+ return { expiresIn: 3600, expiresAt };
+ }
+ }
+
/**
* Helper to get CSRF token from cookie
*/
@@ -127,13 +234,54 @@ class SpringAuthClient {
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
- const token = localStorage.getItem('stirling_jwt');
+ let token = localStorage.getItem('stirling_jwt');
if (!token) {
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
+ if (await isDesktopSaaSAuthMode()) {
+ let tokenExpiry = this.getTokenExpiry(token);
+ if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
+ const refreshed = await refreshPlatformSession();
+ if (!refreshed) {
+ localStorage.removeItem('stirling_jwt');
+ return { data: { session: null }, error: null };
+ }
+
+ const refreshedToken = localStorage.getItem('stirling_jwt');
+ if (!refreshedToken) {
+ localStorage.removeItem('stirling_jwt');
+ return { data: { session: null }, error: null };
+ }
+
+ token = refreshedToken;
+ tokenExpiry = this.getTokenExpiry(token);
+ }
+
+ if (tokenExpiry.expiresIn <= 0) {
+ localStorage.removeItem('stirling_jwt');
+ return { data: { session: null }, error: null };
+ }
+
+ const platformUser = await getPlatformSessionUser();
+
+ const session: Session = {
+ user: {
+ id: platformUser?.email || platformUser?.username || 'desktop-saas-user',
+ email: platformUser?.email || '',
+ username: platformUser?.username || platformUser?.email || 'User',
+ role: 'USER',
+ },
+ access_token: token,
+ expires_in: tokenExpiry.expiresIn,
+ expires_at: tokenExpiry.expiresAt,
+ };
+
+ return { data: { session }, error: null };
+ }
+
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
@@ -142,6 +290,8 @@ class SpringAuthClient {
'Authorization': `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
+ // Session bootstrap should not trigger global 401 refresh/redirect loops.
+ skipAuthRedirect: true,
});
// console.debug('[SpringAuth] /me response status:', response.status);
@@ -149,11 +299,12 @@ class SpringAuthClient {
// console.debug('[SpringAuth] /me response data:', data);
// Create session object
+ const tokenExpiry = this.getTokenExpiry(token);
const session: Session = {
user: data.user,
access_token: token,
- expires_in: 3600,
- expires_at: Date.now() + 3600 * 1000,
+ expires_in: tokenExpiry.expiresIn,
+ expires_at: tokenExpiry.expiresAt,
};
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
@@ -161,8 +312,15 @@ class SpringAuthClient {
} catch (error: unknown) {
console.error('[SpringAuth] getSession error:', error);
- // If 401/403, token is invalid - clear it
- if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
+ // If 401/403, token is invalid - try explicit refresh
+ const status = getHttpStatus(error);
+ if (status === 401 || status === 403) {
+ // A 401 during startup can be a race with a concurrent refresh. Try one
+ // explicit refresh before treating the session as invalid.
+ const refreshResult = await this.refreshSession();
+ if (!refreshResult.error && refreshResult.data.session) {
+ return refreshResult;
+ }
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated');
return { data: { session: null }, error: null };
@@ -201,6 +359,12 @@ class SpringAuthClient {
localStorage.setItem('stirling_jwt', token);
// console.log('[SpringAuth] JWT stored in localStorage');
+ // Sync token to platform-specific storage (Tauri store for desktop)
+ await savePlatformToken(token);
+
+ // Calculate adaptive monitoring intervals based on token lifetime
+ this.calculateAdaptiveIntervals(token);
+
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
@@ -382,6 +546,34 @@ class SpringAuthClient {
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
+ if (await isDesktopSaaSAuthMode()) {
+ const refreshed = await refreshPlatformSession();
+ if (!refreshed) {
+ localStorage.removeItem('stirling_jwt');
+ return {
+ data: { session: null },
+ error: { message: 'Token refresh failed - please log in again' },
+ };
+ }
+
+ const { data, error } = await this.getSession();
+ if (error || !data.session) {
+ return {
+ data: { session: null },
+ error: error || { message: 'Token refresh failed - please log in again' },
+ };
+ }
+
+ // Calculate adaptive intervals for desktop SaaS mode
+ const token = localStorage.getItem('stirling_jwt');
+ if (token) {
+ this.calculateAdaptiveIntervals(token);
+ }
+
+ this.notifyListeners('TOKEN_REFRESHED', data.session);
+ return { data, error: null };
+ }
+
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
@@ -396,8 +588,11 @@ class SpringAuthClient {
// Update local storage with new token
localStorage.setItem('stirling_jwt', token);
- // Dispatch custom event for other components to react to JWT availability
- window.dispatchEvent(new CustomEvent('jwt-available'));
+ // Sync token to platform-specific storage (Tauri store for desktop)
+ await savePlatformToken(token);
+
+ // Calculate adaptive monitoring intervals based on token lifetime
+ this.calculateAdaptiveIntervals(token);
const session: Session = {
user: data.user,
@@ -417,7 +612,8 @@ class SpringAuthClient {
localStorage.removeItem('stirling_jwt');
// Handle different error statuses
- if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
+ const status = getHttpStatus(error);
+ if (status === 401 || status === 403) {
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
}
@@ -462,27 +658,36 @@ class SpringAuthClient {
private startSessionMonitoring() {
// Periodically check session validity
- // Since we use HttpOnly cookies, we just need to check with the server
+ // Interval is adaptive based on token lifetime (calculated when token is received)
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
- // (The server will handle token expiry, but we can be proactive)
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
- // Refresh if token expires soon
- if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
- // console.log('[SpringAuth] Proactively refreshing token');
+ // Refresh if token expires soon (threshold is adaptive)
+ if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
+ console.log('[SpringAuth] 🔄 Proactively refreshing token (expires in ' + Math.floor(timeUntilExpiry / 1000) + 's)');
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
}
- }, this.SESSION_CHECK_INTERVAL);
+ }, this.sessionCheckIntervalMs);
+ }
+
+ private restartSessionMonitoring() {
+ // Stop existing interval
+ if (this.sessionCheckInterval) {
+ clearInterval(this.sessionCheckInterval);
+ this.sessionCheckInterval = null;
+ }
+ // Start with new interval
+ this.startSessionMonitoring();
}
public destroy() {
diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx
index e5c1f41caf..2bfc8f1b44 100644
--- a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx
+++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx
@@ -21,7 +21,10 @@ interface SecuritySettingsData {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
- keyRetentionDays?: number;
+ tokenExpiryMinutes?: number;
+ desktopTokenExpiryMinutes?: number;
+ allowedClockSkewSeconds?: number;
+ refreshGraceMinutes?: number;
secureCookie?: boolean;
};
audit?: {
@@ -131,7 +134,10 @@ export default function AdminSecuritySection() {
'security.jwt.persistence': securitySettings.jwt?.persistence,
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
- 'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
+ 'security.jwt.tokenExpiryMinutes': securitySettings.jwt?.tokenExpiryMinutes,
+ 'security.jwt.desktopTokenExpiryMinutes': securitySettings.jwt?.desktopTokenExpiryMinutes,
+ 'security.jwt.allowedClockSkewSeconds': securitySettings.jwt?.allowedClockSkewSeconds,
+ 'security.jwt.refreshGraceMinutes': securitySettings.jwt?.refreshGraceMinutes,
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
// Premium audit settings
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
@@ -382,20 +388,75 @@ export default function AdminSecuritySection() {
+
- {t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}
-
+ {t('admin.settings.security.jwt.tokenExpiryMinutes.label', 'Web Token Expiry (minutes)')}
+
}
- description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
- value={settings?.jwt?.keyRetentionDays || 7}
- onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
+ description={t('admin.settings.security.jwt.tokenExpiryMinutes.description', 'Access token lifetime in minutes for web clients (default: 1440 = 24 hours)')}
+ value={settings?.jwt?.tokenExpiryMinutes || 1440}
+ onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, tokenExpiryMinutes: Number(value) } })}
min={1}
- max={365}
+ max={43200}
+ disabled={!loginEnabled}
+ />
+
+
+
+
+ {t('admin.settings.security.jwt.desktopTokenExpiryMinutes.label', 'Desktop Token Expiry (minutes)')}
+
+
+ }
+ description={t('admin.settings.security.jwt.desktopTokenExpiryMinutes.description', 'Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)')}
+ value={settings?.jwt?.desktopTokenExpiryMinutes || 43200}
+ onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, desktopTokenExpiryMinutes: Number(value) } })}
+ min={1}
+ max={525600}
+ disabled={!loginEnabled}
+ />
+
+
+
+
+ {t('admin.settings.security.jwt.allowedClockSkewSeconds.label', 'Clock Skew Tolerance (seconds)')}
+
+
+ }
+ description={t('admin.settings.security.jwt.allowedClockSkewSeconds.description', 'Tolerance for client/server time drift during token validation (default: 60 seconds)')}
+ value={settings?.jwt?.allowedClockSkewSeconds ?? 60}
+ onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, allowedClockSkewSeconds: Number(value) } })}
+ min={0}
+ max={300}
+ disabled={!loginEnabled}
+ />
+
+
+
+
+ {t('admin.settings.security.jwt.refreshGraceMinutes.label', 'Refresh Grace Period (minutes)')}
+
+
+ }
+ description={t('admin.settings.security.jwt.refreshGraceMinutes.description', 'Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)')}
+ value={settings?.jwt?.refreshGraceMinutes ?? 15}
+ onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, refreshGraceMinutes: Number(value) } })}
+ min={0}
+ max={120}
disabled={!loginEnabled}
/>
diff --git a/frontend/src/proprietary/extensions/platformSessionBridge.ts b/frontend/src/proprietary/extensions/platformSessionBridge.ts
new file mode 100644
index 0000000000..cba00d8862
--- /dev/null
+++ b/frontend/src/proprietary/extensions/platformSessionBridge.ts
@@ -0,0 +1,33 @@
+export interface PlatformSessionUser {
+ username: string;
+ email?: string;
+}
+
+/**
+ * Proprietary/web default: no desktop SaaS auth bridge.
+ */
+export async function isDesktopSaaSAuthMode(): Promise {
+ return false;
+}
+
+/**
+ * Proprietary/web default: no platform user store.
+ */
+export async function getPlatformSessionUser(): Promise {
+ return null;
+}
+
+/**
+ * Proprietary/web default: no platform refresh path.
+ */
+export async function refreshPlatformSession(): Promise {
+ return false;
+}
+
+/**
+ * Proprietary/web default: no platform-specific token storage (uses localStorage only).
+ */
+export async function savePlatformToken(_token: string): Promise {
+ // Web mode: token already saved to localStorage in springAuthClient
+ // No additional platform storage needed
+}
diff --git a/frontend/src/proprietary/services/apiClientSetup.ts b/frontend/src/proprietary/services/apiClientSetup.ts
index 9b1ed75bd6..91c354999c 100644
--- a/frontend/src/proprietary/services/apiClientSetup.ts
+++ b/frontend/src/proprietary/services/apiClientSetup.ts
@@ -1,4 +1,10 @@
-import { AxiosInstance } from 'axios';
+import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
+
+let isRefreshing = false;
+let failedQueue: Array<{
+ resolve: (token: string) => void;
+ reject: (error: Error) => void;
+}> = [];
function getJwtTokenFromStorage(): string | null {
try {
@@ -9,6 +15,24 @@ function getJwtTokenFromStorage(): string | null {
}
}
+function setJwtTokenInStorage(token: string): void {
+ try {
+ localStorage.setItem('stirling_jwt', token);
+ console.debug('[API Client] Stored new JWT token in localStorage');
+ } catch (error) {
+ console.error('[API Client] Failed to store JWT in localStorage:', error);
+ }
+}
+
+function clearJwtTokenFromStorage(): void {
+ try {
+ localStorage.removeItem('stirling_jwt');
+ console.debug('[API Client] Cleared JWT token from localStorage');
+ } catch (error) {
+ console.error('[API Client] Failed to clear JWT from localStorage:', error);
+ }
+}
+
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
@@ -25,6 +49,48 @@ function getXsrfToken(): string | null {
}
}
+function processQueue(error: Error | null, token: string | null = null): void {
+ failedQueue.forEach((prom) => {
+ if (error) {
+ prom.reject(error);
+ } else if (token) {
+ prom.resolve(token);
+ }
+ });
+ failedQueue = [];
+}
+
+async function refreshAuthToken(client: AxiosInstance): Promise {
+ console.log('[API Client] Refreshing expired JWT token...');
+
+ try {
+ const response = await client.post('/api/v1/auth/refresh', {}, {
+ // Don't retry refresh requests to avoid infinite loops
+ headers: { 'X-Skip-Auth-Refresh': 'true' }
+ });
+
+ const newToken = response.data?.session?.access_token;
+ if (!newToken) {
+ throw new Error('No access token in refresh response');
+ }
+
+ setJwtTokenInStorage(newToken);
+ console.log('[API Client] ✅ Token refreshed successfully');
+ return newToken;
+ } catch (error) {
+ console.error('[API Client] ❌ Token refresh failed:', error);
+ clearJwtTokenFromStorage();
+
+ // Redirect to login
+ if (window.location.pathname !== '/login') {
+ console.log('[API Client] Redirecting to login page...');
+ window.location.href = '/login';
+ }
+
+ throw error;
+ }
+}
+
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
@@ -47,4 +113,61 @@ export function setupApiInterceptors(client: AxiosInstance): void {
return Promise.reject(error);
}
);
+
+ // Install response interceptor to handle 401 and auto-refresh token
+ client.interceptors.response.use(
+ (response) => response,
+ async (error: AxiosError) => {
+ const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
+
+ // Skip refresh for auth endpoints or if explicitly disabled
+ // Exception: /auth/me should trigger refresh (used by getSession)
+ if (
+ !originalRequest ||
+ (originalRequest.url?.includes('/api/v1/auth/') && !originalRequest.url?.includes('/api/v1/auth/me')) ||
+ originalRequest.headers?.['X-Skip-Auth-Refresh'] ||
+ originalRequest._retry
+ ) {
+ return Promise.reject(error);
+ }
+
+ // Handle 401 errors by attempting token refresh
+ if (error.response?.status === 401 && getJwtTokenFromStorage()) {
+ console.warn('[API Client] Received 401 error, attempting token refresh...');
+
+ if (isRefreshing) {
+ // Already refreshing - queue this request
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject });
+ })
+ .then((token) => {
+ originalRequest.headers.Authorization = `Bearer ${token}`;
+ return client(originalRequest);
+ })
+ .catch((err) => {
+ return Promise.reject(err);
+ });
+ }
+
+ originalRequest._retry = true;
+ isRefreshing = true;
+
+ try {
+ const newToken = await refreshAuthToken(client);
+ processQueue(null, newToken);
+
+ // Retry original request with new token
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
+ return client(originalRequest);
+ } catch (refreshError) {
+ processQueue(refreshError as Error, null);
+ return Promise.reject(refreshError);
+ } finally {
+ isRefreshing = false;
+ }
+ }
+
+ return Promise.reject(error);
+ }
+ );
}
diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
index dad9866f3c..e866976d59 100644
--- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts
+++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
- appVersion: '2.4.6',
+ appVersion: '2.5.0',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
diff --git a/testing/allEndpointsRemovedSettings.yml b/testing/allEndpointsRemovedSettings.yml
index 4e5503c415..4c23125bca 100644
--- a/testing/allEndpointsRemovedSettings.yml
+++ b/testing/allEndpointsRemovedSettings.yml
@@ -62,7 +62,6 @@ security:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
- keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
From 757a666f5ee700dea363a73b118389e8fa23b1ac Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Mon, 16 Feb 2026 22:01:15 +0000
Subject: [PATCH 3/3] Chore/v2/improve annotation UI (#5724)
---
.../public/locales/en-GB/translation.toml | 105 +--
.../annotation/shared/ColorControl.tsx | 59 ++
.../annotation/shared/OpacityControl.tsx | 60 ++
.../annotation/shared/PropertiesPopover.tsx | 211 ++++++
.../annotation/shared/WidthControl.tsx | 62 ++
.../viewer/AnnotationSelectionMenu.tsx | 620 ++++++++++++++++++
.../core/components/viewer/LocalEmbedPDF.tsx | 3 +-
frontend/src/core/tools/Annotate.tsx | 1 -
.../core/tools/annotate/AnnotationPanel.tsx | 521 +--------------
.../tools/annotate/useAnnotationSelection.ts | 30 +-
10 files changed, 1088 insertions(+), 584 deletions(-)
create mode 100644 frontend/src/core/components/annotation/shared/ColorControl.tsx
create mode 100644 frontend/src/core/components/annotation/shared/OpacityControl.tsx
create mode 100644 frontend/src/core/components/annotation/shared/PropertiesPopover.tsx
create mode 100644 frontend/src/core/components/annotation/shared/WidthControl.tsx
create mode 100644 frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 16660e79d4..62e629f19f 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -1439,13 +1439,16 @@ applyChanges = "Apply Changes"
backgroundColor = "Background colour"
borderOff = "Border: Off"
borderOn = "Border: On"
+changeColor = "Change Colour"
chooseColor = "Choose colour"
circle = "Circle"
clearBackground = "Remove background"
color = "Colour"
contents = "Text"
+delete = "Delete"
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
drawing = "Drawing"
+duplicate = "Duplicate"
editCircle = "Edit Circle"
editInk = "Edit Pen"
editLine = "Edit Line"
@@ -1475,6 +1478,7 @@ notesStamps = "Notes & Stamps"
opacity = "Opacity"
pen = "Pen"
polygon = "Polygon"
+properties = "Properties"
rectangle = "Rectangle"
redo = "Redo"
saveChanges = "Save Changes"
@@ -1500,6 +1504,7 @@ title = "Annotate"
underline = "Underline"
undo = "Undo"
unsupportedType = "This annotation type is not fully supported for editing."
+width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
@@ -4230,6 +4235,56 @@ title = "Page Editor"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
+[viewer]
+cannotPreviewFile = "Cannot Preview File"
+dualPageView = "Dual Page View"
+firstPage = "First Page"
+lastPage = "Last Page"
+nextPage = "Next Page"
+onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
+previousPage = "Previous Page"
+singlePageView = "Single Page View"
+unknownFile = "Unknown file"
+zoomIn = "Zoom In"
+zoomOut = "Zoom Out"
+
+[rightRail]
+closeSelected = "Close Selected Files"
+selectAll = "Select All"
+deselectAll = "Deselect All"
+selectByNumber = "Select by Page Numbers"
+deleteSelected = "Delete Selected Pages"
+closePdf = "Close PDF"
+exportAll = "Export PDF"
+downloadSelected = "Download Selected Files"
+annotations = "Annotations"
+exportSelected = "Export Selected Pages"
+formFill = "Fill Form"
+saveChanges = "Save Changes"
+toggleAttachments = "Toggle Attachments"
+toggleTheme = "Toggle Theme"
+language = "Language"
+toggleAnnotations = "Toggle Annotations Visibility"
+search = "Search PDF"
+panMode = "Pan Mode"
+applyRedactionsFirst = "Apply redactions first"
+rotateLeft = "Rotate Left"
+rotateRight = "Rotate Right"
+toggleSidebar = "Toggle Sidebar"
+toggleBookmarks = "Toggle Bookmarks"
+print = "Print PDF"
+draw = "Draw"
+redact = "Redact"
+exitRedaction = "Exit Redaction Mode"
+save = "Save"
+downloadAll = "Download All"
+saveAll = "Save All"
+
+[textAlign]
+left = "Left"
+center = "Center"
+right = "Right"
+
[pageExtracter]
header = "Extract Pages"
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
@@ -5310,38 +5365,6 @@ title = "High Contrast"
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
title = "Invert All Colours"
-[rightRail]
-annotations = "Annotations"
-applyRedactionsFirst = "Apply redactions first"
-closePdf = "Close PDF"
-closeSelected = "Close Selected Files"
-formFill = "Fill Form"
-deleteSelected = "Delete Selected Pages"
-deselectAll = "Deselect All"
-downloadAll = "Download All"
-downloadSelected = "Download Selected Files"
-draw = "Draw"
-exitRedaction = "Exit Redaction Mode"
-exportAll = "Export PDF"
-exportSelected = "Export Selected Pages"
-language = "Language"
-panMode = "Pan Mode"
-print = "Print PDF"
-redact = "Redact"
-rotateLeft = "Rotate Left"
-rotateRight = "Rotate Right"
-save = "Save"
-saveAll = "Save All"
-saveChanges = "Save Changes"
-search = "Search PDF"
-selectAll = "Select All"
-selectByNumber = "Select by Page Numbers"
-toggleAnnotations = "Toggle Annotations Visibility"
-toggleBookmarks = "Toggle Bookmarks"
-toggleAttachments = "Toggle Attachments"
-toggleSidebar = "Toggle Sidebar"
-toggleTheme = "Toggle Theme"
-
[rotate]
rotateLeft = "Rotate Anticlockwise"
rotateRight = "Rotate Clockwise"
@@ -6179,11 +6202,6 @@ title = "API Documentation"
[tableExtraxt]
tags = "CSV,Table Extraction,extract,convert"
-[textAlign]
-center = "Center"
-left = "Left"
-right = "Right"
-
[theme]
toggle = "Toggle Theme"
@@ -6459,19 +6477,6 @@ fileManager = "File Manager"
pageEditor = "Page Editor"
viewer = "Viewer"
-[viewer]
-cannotPreviewFile = "Cannot Preview File"
-dualPageView = "Dual Page View"
-firstPage = "First Page"
-lastPage = "Last Page"
-nextPage = "Next Page"
-onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
-previousPage = "Previous Page"
-singlePageView = "Single Page View"
-unknownFile = "Unknown file"
-zoomIn = "Zoom In"
-zoomOut = "Zoom Out"
-
[viewer.attachments]
title = "Attachments"
searchPlaceholder = "Search attachments"
diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx
new file mode 100644
index 0000000000..51153c071f
--- /dev/null
+++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx
@@ -0,0 +1,59 @@
+import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core';
+import { useState } from 'react';
+
+interface ColorControlProps {
+ value: string;
+ onChange: (color: string) => void;
+ label: string;
+ disabled?: boolean;
+}
+
+export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
+ const [opened, setOpened] = useState(false);
+
+ return (
+
+
+
+ setOpened(!opened)}
+ disabled={disabled}
+ styles={{
+ root: {
+ flexShrink: 0,
+ backgroundColor: 'var(--bg-raised)',
+ border: '1px solid var(--border-default)',
+ color: 'var(--text-secondary)',
+ '&:hover': {
+ backgroundColor: 'var(--hover-bg)',
+ borderColor: 'var(--border-strong)',
+ color: 'var(--text-primary)',
+ },
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx
new file mode 100644
index 0000000000..27b1f10dd9
--- /dev/null
+++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx
@@ -0,0 +1,60 @@
+import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { useState } from 'react';
+import OpacityIcon from '@mui/icons-material/Opacity';
+
+interface OpacityControlProps {
+ value: number; // 0-100
+ onChange: (value: number) => void;
+ disabled?: boolean;
+}
+
+export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+
+ return (
+
+
+
+ setOpened(!opened)}
+ disabled={disabled}
+ styles={{
+ root: {
+ flexShrink: 0,
+ backgroundColor: 'var(--bg-raised)',
+ border: '1px solid var(--border-default)',
+ color: 'var(--text-secondary)',
+ '&:hover': {
+ backgroundColor: 'var(--hover-bg)',
+ borderColor: 'var(--border-strong)',
+ color: 'var(--text-primary)',
+ },
+ },
+ }}
+ >
+
+
+
+
+
+
+
+ {t('annotation.opacity', 'Opacity')}
+
+ `${val}%`}
+ />
+
+
+
+ );
+}
diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx
new file mode 100644
index 0000000000..bde9609b5f
--- /dev/null
+++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx
@@ -0,0 +1,211 @@
+import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { useState } from 'react';
+import TuneIcon from '@mui/icons-material/Tune';
+import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
+import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
+import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
+
+type AnnotationType = 'text' | 'note' | 'shape';
+
+interface PropertiesPopoverProps {
+ annotationType: AnnotationType;
+ annotation: any;
+ onUpdate: (patch: Record) => void;
+ disabled?: boolean;
+}
+
+export function PropertiesPopover({
+ annotationType,
+ annotation,
+ onUpdate,
+ disabled = false,
+}: PropertiesPopoverProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+
+ const obj = annotation?.object;
+
+ // Get current values
+ const fontSize = obj?.fontSize ?? 14;
+ const textAlign = obj?.textAlign;
+ const currentAlign =
+ typeof textAlign === 'number'
+ ? textAlign === 1
+ ? 'center'
+ : textAlign === 2
+ ? 'right'
+ : 'left'
+ : textAlign === 'center'
+ ? 'center'
+ : textAlign === 'right'
+ ? 'right'
+ : 'left';
+
+ // For shapes
+ const opacity = Math.round((obj?.opacity ?? 1) * 100);
+ const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2;
+ const borderVisible = strokeWidth > 0;
+
+ const renderTextNoteControls = () => (
+
+ {/* Font Size */}
+
+
+ {t('annotation.fontSize', 'Font size')}
+
+ onUpdate({ fontSize: val })}
+ min={8}
+ max={32}
+ label={(val) => `${val}pt`}
+ />
+
+
+ {/* Opacity */}
+
+
+ {t('annotation.opacity', 'Opacity')}
+
+ onUpdate({ opacity: val / 100 })}
+ min={10}
+ max={100}
+ label={(val) => `${val}%`}
+ />
+
+
+ {/* Text Alignment */}
+
+
+ {t('annotation.textAlignment', 'Text Alignment')}
+
+
+ onUpdate({ textAlign: 0 })}
+ size="md"
+ >
+
+
+ onUpdate({ textAlign: 1 })}
+ size="md"
+ >
+
+
+ onUpdate({ textAlign: 2 })}
+ size="md"
+ >
+
+
+
+
+
+ );
+
+ const renderShapeControls = () => (
+
+ {/* Opacity */}
+
+
+ {t('annotation.opacity', 'Opacity')}
+
+ {
+ const newOpacity = val / 100;
+ onUpdate({
+ opacity: newOpacity,
+ strokeOpacity: newOpacity,
+ fillOpacity: newOpacity,
+ });
+ }}
+ min={10}
+ max={100}
+ label={(val) => `${val}%`}
+ />
+
+
+ {/* Stroke Width */}
+
+
+
+
+ {t('annotation.strokeWidth', 'Stroke')}
+
+ {
+ onUpdate({
+ borderWidth: val,
+ strokeWidth: val,
+ lineWidth: val,
+ });
+ }}
+ min={0}
+ max={12}
+ label={(val) => `${val}pt`}
+ />
+
+ {
+ const newValue = borderVisible ? 0 : 1;
+ onUpdate({
+ borderWidth: newValue,
+ strokeWidth: newValue,
+ lineWidth: newValue,
+ });
+ }}
+ >
+ {borderVisible
+ ? t('annotation.borderOn', 'Border: On')
+ : t('annotation.borderOff', 'Border: Off')}
+
+
+
+
+ );
+
+ return (
+
+
+
+ setOpened(!opened)}
+ disabled={disabled}
+ styles={{
+ root: {
+ flexShrink: 0,
+ backgroundColor: 'var(--bg-raised)',
+ border: '1px solid var(--border-default)',
+ color: 'var(--text-secondary)',
+ '&:hover': {
+ backgroundColor: 'var(--hover-bg)',
+ borderColor: 'var(--border-strong)',
+ color: 'var(--text-primary)',
+ },
+ },
+ }}
+ >
+
+
+
+
+
+ {(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
+ {annotationType === 'shape' && renderShapeControls()}
+
+
+ );
+}
diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx
new file mode 100644
index 0000000000..b99d35c996
--- /dev/null
+++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx
@@ -0,0 +1,62 @@
+import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { useState } from 'react';
+import LineWeightIcon from '@mui/icons-material/LineWeight';
+
+interface WidthControlProps {
+ value: number;
+ onChange: (value: number) => void;
+ min: number; // 1 for ink, 0 for shapes
+ max: number; // 12 for ink, 20 for highlighter
+ disabled?: boolean;
+}
+
+export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+
+ return (
+
+
+
+ setOpened(!opened)}
+ disabled={disabled}
+ styles={{
+ root: {
+ flexShrink: 0,
+ backgroundColor: 'var(--bg-raised)',
+ border: '1px solid var(--border-default)',
+ color: 'var(--text-secondary)',
+ '&:hover': {
+ backgroundColor: 'var(--hover-bg)',
+ borderColor: 'var(--border-strong)',
+ color: 'var(--text-primary)',
+ },
+ },
+ }}
+ >
+
+
+
+
+
+
+
+ {t('annotation.width', 'Width')}
+
+ `${val}pt`}
+ />
+
+
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx
new file mode 100644
index 0000000000..088e315630
--- /dev/null
+++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx
@@ -0,0 +1,620 @@
+import { ActionIcon, Tooltip, Group } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { createPortal } from 'react-dom';
+import { useEffect, useState, useRef, useCallback } from 'react';
+import DeleteIcon from '@mui/icons-material/Delete';
+import EditIcon from '@mui/icons-material/Edit';
+import { useAnnotation } from '@embedpdf/plugin-annotation/react';
+import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
+import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
+import { WidthControl } from '@app/components/annotation/shared/WidthControl';
+import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover';
+import { ColorControl } from '@app/components/annotation/shared/ColorControl';
+
+/**
+ * Props interface matching EmbedPDF's annotation selection menu pattern
+ * This matches the type from @embedpdf/plugin-annotation
+ */
+export interface AnnotationSelectionMenuProps {
+ documentId?: string;
+ context?: {
+ type: 'annotation';
+ annotation: any;
+ pageIndex: number;
+ };
+ selected: boolean;
+ menuWrapperProps?: {
+ ref?: (node: HTMLDivElement | null) => void;
+ style?: React.CSSProperties;
+ };
+}
+
+export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) {
+ const activeDocumentId = useActiveDocumentId();
+
+ // Don't render until we have a valid document ID
+ if (!activeDocumentId) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown';
+
+function AnnotationSelectionMenuInner({
+ documentId,
+ context,
+ selected,
+ menuWrapperProps,
+}: AnnotationSelectionMenuProps & { documentId: string }) {
+ const annotation = context?.annotation;
+ const pageIndex = context?.pageIndex;
+ const { t } = useTranslation();
+ const { provides } = useAnnotation(documentId);
+ const wrapperRef = useRef(null);
+ const textareaRef = useRef(null);
+ const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
+ const [isTextEditorOpen, setIsTextEditorOpen] = useState(false);
+ const [textDraft, setTextDraft] = useState('');
+ const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null);
+
+ // Merge refs - menuWrapperProps.ref is a callback ref
+ const setRef = useCallback((node: HTMLDivElement | null) => {
+ wrapperRef.current = node;
+ // Call the EmbedPDF ref callback
+ menuWrapperProps?.ref?.(node);
+ }, [menuWrapperProps]);
+
+ // Type detection
+ const getAnnotationType = useCallback((): AnnotationType => {
+ const type = annotation?.object?.type;
+ const toolId = annotation?.object?.customData?.toolId;
+
+ // Map type numbers to categories
+ if ([9, 10, 11, 12].includes(type)) return 'textMarkup';
+ if (type === 15) {
+ return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
+ }
+ if (type === 3) {
+ return toolId === 'note' ? 'note' : 'text';
+ }
+ if ([5, 6, 7].includes(type)) return 'shape';
+ if ([4, 8].includes(type)) return 'line';
+ if (type === 13) return 'stamp';
+
+ return 'unknown';
+ }, [annotation]);
+
+ // Calculate menu width based on annotation type
+ const calculateWidth = (annotationType: AnnotationType): number => {
+ switch (annotationType) {
+ case 'stamp':
+ return 80;
+ case 'inkHighlighter':
+ return 220;
+ case 'shape':
+ return 200;
+ default:
+ return 180;
+ }
+ };
+
+ // Get annotation properties
+ const obj = annotation?.object;
+ const annotationType = getAnnotationType();
+ const annotationId = obj?.id;
+
+ // Get current colors
+ const getCurrentColor = (): string => {
+ if (!obj) return '#000000';
+ const type = obj.type;
+ // Text annotations use textColor
+ if (type === 3) return obj.textColor || obj.color || '#000000';
+ // Shape annotations use strokeColor
+ if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
+ // Default to color property
+ return obj.color || obj.strokeColor || '#000000';
+ };
+
+ const getStrokeColor = (): string => {
+ return obj?.strokeColor || obj?.color || '#000000';
+ };
+
+ const getFillColor = (): string => {
+ return obj?.color || obj?.fillColor || '#0000ff';
+ };
+
+ const getBackgroundColor = (): string => {
+ // Check multiple possible properties for background color
+ return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
+ };
+
+ const getTextColor = (): string => {
+ return obj?.textColor || obj?.color || '#000000';
+ };
+
+ const getOpacity = (): number => {
+ return Math.round((obj?.opacity ?? 1) * 100);
+ };
+
+ const getWidth = (): number => {
+ return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
+ };
+
+ // Handlers
+ const handleDelete = useCallback(() => {
+ if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
+ provides.deleteAnnotation(pageIndex, annotationId);
+ }
+ }, [provides, annotationId, pageIndex]);
+
+ const handleOpenTextEditor = useCallback(() => {
+ if (!annotation) return;
+
+ // Try to find the annotation element in the DOM
+ const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement;
+
+ let fontSize = (obj?.fontSize || 14) * 1.33;
+ let fontFamily = 'Helvetica';
+
+ if (annotationElement) {
+ const rect = annotationElement.getBoundingClientRect();
+
+ // Try multiple selectors to find the text element
+ const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement;
+ if (textElement) {
+ const computedStyle = window.getComputedStyle(textElement);
+ const computedSize = parseFloat(computedStyle.fontSize);
+ if (computedSize && computedSize > 0) {
+ fontSize = computedSize;
+ }
+ fontFamily = computedStyle.fontFamily || fontFamily;
+ }
+
+ setTextBoxPosition({
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ fontSize: fontSize,
+ fontFamily: fontFamily,
+ });
+ } else if (wrapperRef.current) {
+ // Fallback to wrapper position
+ const rect = wrapperRef.current.getBoundingClientRect();
+ setTextBoxPosition({
+ top: rect.top,
+ left: rect.left,
+ width: Math.max(rect.width, 200),
+ height: Math.max(rect.height, 50),
+ fontSize: fontSize,
+ fontFamily: fontFamily,
+ });
+ } else {
+ return;
+ }
+
+ setTextDraft(obj?.contents || '');
+ setIsTextEditorOpen(true);
+
+ // Focus the textarea after it renders
+ setTimeout(() => {
+ textareaRef.current?.focus();
+ textareaRef.current?.select();
+ }, 0);
+ }, [obj, annotation, annotationId]);
+
+ const handleSaveText = useCallback(() => {
+ if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
+
+ provides.updateAnnotation(pageIndex, annotationId, {
+ contents: textDraft,
+ });
+ setIsTextEditorOpen(false);
+ setTextBoxPosition(null);
+ }, [provides, annotationId, pageIndex, textDraft]);
+
+ const handleCloseTextEdit = useCallback(() => {
+ setIsTextEditorOpen(false);
+ setTextBoxPosition(null);
+ }, []);
+
+ const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
+ if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
+
+ const type = obj?.type;
+ const patch: any = {};
+
+ if (target === 'stroke') {
+ // Shape stroke - preserve fill color
+ patch.strokeColor = color;
+ patch.color = obj?.color || '#0000ff'; // Preserve fill
+ patch.strokeWidth = getWidth();
+ } else if (target === 'fill') {
+ // Shape fill - preserve stroke color
+ patch.color = color;
+ patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke
+ patch.strokeWidth = getWidth();
+ } else if (target === 'background') {
+ // Background color for text/note - set multiple properties for compatibility
+ patch.backgroundColor = color;
+ patch.fillColor = color;
+ patch.color = color;
+ } else if (target === 'text') {
+ // Text color for text/note - TRY PROPERTY COMBINATIONS
+ patch.textColor = color;
+ patch.fontColor = color; // EmbedPDF might expect this instead
+
+ // Include font metadata (EmbedPDF might require these together)
+ patch.fontSize = obj?.fontSize ?? 14;
+ patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
+
+ // Re-submit text content
+ patch.contents = obj?.contents ?? '';
+ } else {
+ // Main color - for highlights, ink, etc.
+ patch.color = color;
+
+ // For text markup annotations (highlight, underline, strikeout, squiggly)
+ if ([9, 10, 11, 12].includes(type)) {
+ patch.strokeColor = color;
+ patch.fillColor = color;
+ patch.opacity = obj?.opacity ?? 1;
+ }
+
+ // For line annotations (type 4, 8), include stroke properties
+ if ([4, 8].includes(type)) {
+ patch.strokeColor = color;
+ patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
+ patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
+ }
+
+ // For ink annotations (type 15), include all stroke-related properties
+ if (type === 15) {
+ patch.strokeColor = color;
+ patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
+ patch.opacity = obj?.opacity ?? 1;
+ }
+ }
+
+ provides.updateAnnotation(pageIndex, annotationId, patch);
+ }, [provides, annotationId, pageIndex, obj]);
+
+ const handleOpacityChange = useCallback((opacity: number) => {
+ if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
+
+ provides.updateAnnotation(pageIndex, annotationId, {
+ opacity: opacity / 100,
+ });
+ }, [provides, annotationId, pageIndex]);
+
+ const handleWidthChange = useCallback((width: number) => {
+ if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
+
+ provides.updateAnnotation(pageIndex, annotationId, {
+ strokeWidth: width,
+ });
+ }, [provides, annotationId, pageIndex]);
+
+ const handlePropertiesUpdate = useCallback((patch: Record) => {
+ if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
+
+ provides.updateAnnotation(pageIndex, annotationId, patch);
+ }, [provides, annotationId, pageIndex]);
+
+ // Render button groups based on annotation type
+ const renderButtons = () => {
+ const commonButtonStyles = {
+ root: {
+ flexShrink: 0,
+ backgroundColor: 'var(--bg-raised)',
+ border: '1px solid var(--border-default)',
+ color: 'var(--text-secondary)',
+ '&:hover': {
+ backgroundColor: 'var(--hover-bg)',
+ borderColor: 'var(--border-strong)',
+ color: 'var(--text-primary)',
+ },
+ },
+ };
+
+ const EditTextButton = () => (
+
+
+
+
+
+ );
+
+ const DeleteButton = () => (
+
+
+
+
+
+ );
+
+ switch (annotationType) {
+ case 'textMarkup':
+ return (
+ <>
+ handleColorChange(color, 'main')}
+ label={t('annotation.changeColor', 'Change Colour')}
+ />
+
+
+ >
+ );
+
+ case 'ink':
+ return (
+ <>
+ handleColorChange(color, 'main')}
+ label={t('annotation.changeColor', 'Change Colour')}
+ />
+
+
+ >
+ );
+
+ case 'inkHighlighter':
+ return (
+ <>
+ handleColorChange(color, 'main')}
+ label={t('annotation.changeColor', 'Change Colour')}
+ />
+
+
+
+ >
+ );
+
+ case 'text':
+ case 'note':
+ return (
+ <>
+ handleColorChange(color, 'text')}
+ label={t('annotation.color', 'Color')}
+ />
+ handleColorChange(color, 'background')}
+ label={t('annotation.backgroundColor', 'Background color')}
+ />
+
+
+
+ >
+ );
+
+ case 'shape':
+ return (
+ <>
+ handleColorChange(color, 'stroke')}
+ label={t('annotation.strokeColor', 'Stroke Colour')}
+ />
+ handleColorChange(color, 'fill')}
+ label={t('annotation.fillColor', 'Fill Colour')}
+ />
+
+
+ >
+ );
+
+ case 'line':
+ return (
+ <>
+ handleColorChange(color, 'main')}
+ label={t('annotation.changeColor', 'Change Colour')}
+ />
+
+
+ >
+ );
+
+ case 'stamp':
+ return ;
+
+ default:
+ return (
+ <>
+ handleColorChange(color, 'main')}
+ label={t('annotation.changeColor', 'Change Colour')}
+ />
+
+ >
+ );
+ }
+ };
+
+ // Calculate position for portal based on wrapper element
+ useEffect(() => {
+ if (!selected || !annotation || !wrapperRef.current) {
+ setMenuPosition(null);
+ return;
+ }
+
+ const updatePosition = () => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper) {
+ setMenuPosition(null);
+ return;
+ }
+
+ const wrapperRect = wrapper.getBoundingClientRect();
+ // Position menu below the wrapper, centered
+ // Use getBoundingClientRect which gives viewport-relative coordinates
+ // Since we're using fixed positioning in the portal, we don't need to add scroll offsets
+ setMenuPosition({
+ top: wrapperRect.bottom + 8,
+ left: wrapperRect.left + wrapperRect.width / 2,
+ });
+ };
+
+ updatePosition();
+
+ // Update position on scroll/resize
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [selected, annotation]);
+
+ // Early return AFTER all hooks have been called
+ if (!selected || !annotation) return null;
+
+ const menuContent = menuPosition ? (
+
+
+ {renderButtons()}
+
+
+ ) : null;
+
+ const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
+
+
+ ) : null;
+
+ const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
+
+ return (
+ <>
+ {/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */}
+
+ {typeof document !== 'undefined' && menuContent
+ ? createPortal(menuContent, document.body)
+ : null}
+ {typeof document !== 'undefined' && textEditorOverlay
+ ? createPortal(textEditorOverlay, document.body)
+ : null}
+ >
+ );
+}
diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx
index 7f004095f0..45cfbde789 100644
--- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx
+++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx
@@ -50,6 +50,7 @@ import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler';
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
+import { AnnotationSelectionMenu } from '@app/components/viewer/AnnotationSelectionMenu';
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPermissionsAPIBridge';
@@ -752,7 +753,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
documentId={documentId}
pageIndex={pageIndex}
selectionOutlineColor="#007ACC"
- selectionMenu={(props) => }
+ selectionMenu={(props) => }
/>
)}
diff --git a/frontend/src/core/tools/Annotate.tsx b/frontend/src/core/tools/Annotate.tsx
index c231124b0a..ebf89de6dc 100644
--- a/frontend/src/core/tools/Annotate.tsx
+++ b/frontend/src/core/tools/Annotate.tsx
@@ -372,7 +372,6 @@ const Annotate = (_props: BaseToolProps) => {
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
- manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
diff --git a/frontend/src/core/tools/annotate/AnnotationPanel.tsx b/frontend/src/core/tools/annotate/AnnotationPanel.tsx
index 09d0ebf8f8..a0797c4e56 100644
--- a/frontend/src/core/tools/annotate/AnnotationPanel.tsx
+++ b/frontend/src/core/tools/annotate/AnnotationPanel.tsx
@@ -1,7 +1,7 @@
-import { useMemo, useRef, useState } from 'react';
+import { useMemo, useState } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
-import { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Textarea, Tooltip, Paper } from '@mantine/core';
+import { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Tooltip, Paper } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
@@ -111,7 +111,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
const { t } = useTranslation();
const [colorPickerTarget, setColorPickerTarget] = useState(null);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
- const selectedUpdateTimer = useRef | null>(null);
const {
activeTool,
@@ -122,10 +121,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
buildToolOptions,
deriveToolFromAnnotation,
selectedAnn,
- selectedTextDraft,
- setSelectedTextDraft,
- selectedFontSize,
- setSelectedFontSize,
annotationApiRef,
viewerContext,
setPlacementMode,
@@ -549,512 +544,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
);
- const selectedDerivedTool = selectedAnn?.object ? deriveToolFromAnnotation(selectedAnn.object) : undefined;
-
- const selectedAnnotationControls = selectedAnn && (() => {
- const rawType = selectedAnn.object?.type;
- const toolId = selectedDerivedTool ?? deriveToolFromAnnotation(selectedAnn.object);
- const derivedType =
- toolId === 'highlight' ? 9
- : toolId === 'underline' ? 10
- : toolId === 'squiggly' ? 11
- : toolId === 'strikeout' ? 12
- : toolId === 'line' ? 4
- : toolId === 'square' ? 5
- : toolId === 'circle' ? 6
- : toolId === 'polygon' ? 7
- : toolId === 'polyline' ? 8
- : toolId === 'text' ? 3
- : toolId === 'note' ? 3
- : toolId === 'stamp' ? 13
- : toolId === 'ink' ? 15
- : undefined;
- const type = typeof rawType === 'number' ? rawType : derivedType;
-
- if (toolId && ['highlight', 'underline', 'strikeout', 'squiggly'].includes(toolId)) {
- return (
-
-
- {t('annotation.editTextMarkup', 'Edit Text Markup')}
-
- {t('annotation.color', 'Color')}
- {
- setColorPickerTarget('highlight');
- setIsColorPickerOpen(true);
- }}
- />
-
-
- {t('annotation.opacity', 'Opacity')}
- {
- annotationApiRef?.current?.updateAnnotation?.(
- selectedAnn.object?.pageIndex ?? 0,
- selectedAnn.object?.id,
- { opacity: value / 100 }
- );
- }}
- />
-
-
-
- );
- }
-
- if (type === 15 || toolId === 'inkHighlighter' || toolId === 'ink') {
- const isHighlighter = toolId === 'inkHighlighter';
- const thicknessValue =
- selectedAnn.object?.strokeWidth ??
- selectedAnn.object?.borderWidth ??
- selectedAnn.object?.lineWidth ??
- selectedAnn.object?.thickness ??
- (isHighlighter ? freehandHighlighterWidth : inkWidth);
- const colorValue = selectedAnn.object?.color ?? (isHighlighter ? highlightColor : inkColor);
- const opacityValue = Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || (isHighlighter ? highlightOpacity : 100));
- return (
-
-
-
- {isHighlighter ? t('annotation.freehandHighlighter', 'Freehand Highlighter') : t('annotation.editInk', 'Edit Pen')}
-
-
- {t('annotation.color', 'Color')}
- {
- setColorPickerTarget(isHighlighter ? 'highlight' : 'ink');
- setIsColorPickerOpen(true);
- }}
- />
-
- {isHighlighter && (
-
- {t('annotation.opacity', 'Opacity')}
- {
- setHighlightOpacity(value);
- annotationApiRef?.current?.updateAnnotation?.(
- selectedAnn.object?.pageIndex ?? 0,
- selectedAnn.object?.id,
- { opacity: value / 100 }
- );
- }}
- />
-
- )}
-
- {t('annotation.strokeWidth', 'Width')}
- {
- annotationApiRef?.current?.updateAnnotation?.(
- selectedAnn.object?.pageIndex ?? 0,
- selectedAnn.object?.id,
- {
- strokeWidth: value,
- borderWidth: value,
- lineWidth: value,
- thickness: value,
- }
- );
- if (isHighlighter) {
- setFreehandHighlighterWidth?.(value);
- } else {
- setInkWidth(value);
- }
- }}
- />
-
-
-
- );
- }
-
- if (type === 3 || toolId === 'text' || toolId === 'note') {
- const isNote = toolId === 'note';
- const selectedBackground =
- selectedAnn.object?.backgroundColor ??
- (isNote ? noteBackgroundColor || '#ffffff' : textBackgroundColor || '#ffffff');
- const alignValue = selectedAnn.object?.textAlign;
- const currentAlign =
- typeof alignValue === 'number'
- ? alignValue === 1
- ? 'center'
- : alignValue === 2
- ? 'right'
- : 'left'
- : alignValue === 'center'
- ? 'center'
- : alignValue === 'right'
- ? 'right'
- : 'left';
-
- return (
-
-
- {isNote ? t('annotation.editNote', 'Edit Sticky Note') : t('annotation.editText', 'Edit Text Box')}
-
- {t('annotation.color', 'Color')}
- {
- setColorPickerTarget('text');
- setIsColorPickerOpen(true);
- }}
- />
-
-
- {t('annotation.backgroundColor', 'Background color')}
-
- {
- setColorPickerTarget(isNote ? 'noteBackground' : 'textBackground');
- setIsColorPickerOpen(true);
- }}
- />
- {
- if (isNote) {
- setNoteBackgroundColor('');
- } else {
- setTextBackgroundColor('');
- }
- annotationApiRef?.current?.updateAnnotation?.(
- selectedAnn.object?.pageIndex ?? 0,
- selectedAnn.object?.id,
- { backgroundColor: 'transparent', fillColor: 'transparent' }
- );
- }}
- >
- {t('annotation.clearBackground', 'Remove background')}
-
-
-
-
-
- );
- }
-
- if (type === 13 || toolId === 'stamp') {
- const imageSrc = selectedAnn.object?.imageSrc || selectedAnn.object?.data || selectedAnn.object?.url;
- return (
-
-
- {t('annotation.stamp', 'Add Image')}
- {imageSrc ? (
-
- {t('annotation.imagePreview', 'Preview')}
-
-
- ) : (
-
- {t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}
-
- )}
-
- {t('annotation.editStampHint', 'To change the image, delete this stamp and add a new one.')}
-
-
-
- );
- }
-
- if ((type !== undefined && [4, 8].includes(type)) || toolId === 'line' || toolId === 'polyline') {
- return (
-
-
- {t('annotation.editLine', 'Edit Line')}
-
- {t('annotation.color', 'Color')}
- {
- setColorPickerTarget('shapeStroke');
- setIsColorPickerOpen(true);
- }}
- />
-
-
- {t('annotation.opacity', 'Opacity')}
- {
- annotationApiRef?.current?.updateAnnotation?.(
- selectedAnn.object?.pageIndex ?? 0,
- selectedAnn.object?.id,
- { opacity: value / 100 }
- );
- }}
- />
-
-
- {t('annotation.strokeWidth', 'Width')}
- {
- annotationApiRef?.current?.updateAnnotation?.(
- selectedAnn.object?.pageIndex ?? 0,
- selectedAnn.object?.id,
- {
- borderWidth: value,
- strokeWidth: value,
- lineWidth: value,
- }
- );
- setShapeThickness(value);
- }}
- />
-
-
-
- );
- }
-
- if ((type !== undefined && [5, 6, 7].includes(type)) || toolId === 'square' || toolId === 'circle' || toolId === 'polygon') {
- const shapeName = type === 5 ? 'Square' : type === 6 ? 'Circle' : 'Polygon';
- const strokeColorValue = selectedAnn.object?.strokeColor ?? shapeStrokeColor;
- const fillColorValue = selectedAnn.object?.color ?? shapeFillColor;
- const opacityValue = Math.round(((selectedAnn.object?.opacity ?? shapeOpacity / 100) * 100) || 100);
- const pageIndex = selectedAnn.object?.pageIndex ?? 0;
- const annId = selectedAnn.object?.id;
- return (
-
-
- {t(`annotation.edit${shapeName}`, `Edit ${shapeName}`)}
-
-
- {t('annotation.strokeColor', 'Stroke Color')}
- {
- setColorPickerTarget('shapeStroke');
- setIsColorPickerOpen(true);
- }}
- />
-
-
- {t('annotation.fillColor', 'Fill Color')}
- {
- setColorPickerTarget('shapeFill');
- setIsColorPickerOpen(true);
- }}
- />
-
-
-
- {t('annotation.opacity', 'Opacity')}
- {
- setShapeOpacity(value);
- setShapeStrokeOpacity(value);
- setShapeFillOpacity(value);
- if (annId) {
- annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
- opacity: value / 100,
- strokeOpacity: value / 100,
- fillOpacity: value / 100,
- });
- }
- }}
- />
-
-
-
- {t('annotation.strokeWidth', 'Stroke')}
- {
- if (annId) {
- annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
- borderWidth: value,
- strokeWidth: value,
- lineWidth: value,
- });
- }
- setShapeThickness(value);
- }}
- />
-
- {
- const newValue = (selectedAnn.object?.borderWidth ?? shapeThickness) === 0 ? 1 : 0;
- if (annId) {
- annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
- borderWidth: newValue,
- strokeWidth: newValue,
- lineWidth: newValue,
- });
- }
- setShapeThickness(newValue);
- }}
- >
- {(selectedAnn.object?.borderWidth ?? shapeThickness) === 0
- ? t('annotation.borderOff', 'Border: Off')
- : t('annotation.borderOn', 'Border: On')
- }
-
-
-
-
- );
- }
-
- return (
-
-
- {t('annotation.editSelected', 'Edit Annotation')}
- {t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}
-
-
- );
- })();
-
const colorPickerComponent = (
- {activeTool !== 'select' && defaultStyleControls}
-
- {activeTool === 'select' && selectedAnn && selectedAnnotationControls}
-
- {activeTool === 'select' && !selectedAnn && defaultStyleControls}
+ {activeTool === 'stamp' && defaultStyleControls}
{colorPickerComponent}
diff --git a/frontend/src/core/tools/annotate/useAnnotationSelection.ts b/frontend/src/core/tools/annotate/useAnnotationSelection.ts
index 020408b59d..382ae2f805 100644
--- a/frontend/src/core/tools/annotate/useAnnotationSelection.ts
+++ b/frontend/src/core/tools/annotate/useAnnotationSelection.ts
@@ -5,7 +5,6 @@ interface UseAnnotationSelectionParams {
annotationApiRef: React.RefObject;
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
activeToolRef: React.MutableRefObject;
- manualToolSwitch: React.MutableRefObject;
setActiveTool: (toolId: AnnotationToolId) => void;
setSelectedTextDraft: (text: string) => void;
setSelectedFontSize: (size: number) => void;
@@ -34,6 +33,7 @@ interface UseAnnotationSelectionParams {
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
+const STAY_ACTIVE_TOOL_IDS = [...MARKUP_TOOL_IDS, ...DRAWING_TOOL_IDS] as const;
const isTextMarkupAnnotation = (annotation: any): boolean => {
const toolId =
@@ -55,6 +55,9 @@ const isTextMarkupAnnotation = (annotation: any): boolean => {
};
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
+ // Text markup tools (highlight, underline, strikeout, squiggly) and drawing tools (ink, inkHighlighter) stay active
+ // All other tools switch to select mode after placement
+
const toolId =
derivedTool ||
annotation?.customData?.annotationToolId ||
@@ -62,12 +65,17 @@ const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
- if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_TOOL_IDS.includes(toolId as any))) {
+ // Check if it's a tool that should stay active
+ if (toolId && STAY_ACTIVE_TOOL_IDS.includes(toolId as any)) {
return true;
}
- const type = annotation?.type ?? annotation?.object?.type;
- if (typeof type === 'number' && type === 15) return true; // ink family
- if (isTextMarkupAnnotation(annotation)) return true;
+
+ // Check if it's a markup annotation by type/subtype
+ if (isTextMarkupAnnotation(annotation)) {
+ return true;
+ }
+
+ // All other tools (text, note, shapes, lines, stamps) switch to select
return false;
};
@@ -75,7 +83,6 @@ export function useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
- manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
@@ -226,8 +233,8 @@ export function useAnnotationSelection({
},
[
activeToolRef,
+ annotationApiRef,
deriveToolFromAnnotation,
- manualToolSwitch,
setActiveTool,
setInkWidth,
setNoteBackgroundColor,
@@ -252,7 +259,6 @@ export function useAnnotationSelection({
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
- shouldStayOnPlacementTool,
]
);
@@ -304,9 +310,7 @@ export function useAnnotationSelection({
const tool =
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
currentTool;
- const stayOnPlacement =
- shouldStayOnPlacementTool(eventAnn, tool) ||
- (tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
+ const stayOnPlacement = shouldStayOnPlacementTool(eventAnn, tool);
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
@@ -318,9 +322,7 @@ export function useAnnotationSelection({
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
const derivedAfter =
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
- const stayOnPlacementAfter =
- shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
- (derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
+ const stayOnPlacementAfter = shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter);
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
activeToolRef.current = 'select';
setActiveTool('select');