mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757a666f5e | ||
|
|
558c75a2b1 | ||
|
|
da2eb54fe8 | ||
|
|
772dd4632e | ||
|
|
d5cf77cf50 |
@@ -0,0 +1,49 @@
|
||||
package stirling.software.common.constants;
|
||||
|
||||
/**
|
||||
* Centralized constants for JWT token management.
|
||||
*
|
||||
* <p>These defaults are used when configuration values are not explicitly set.
|
||||
*/
|
||||
public final class JwtConstants {
|
||||
|
||||
private JwtConstants() {
|
||||
throw new UnsupportedOperationException("Utility class");
|
||||
}
|
||||
|
||||
/** Default JWT access token lifetime in minutes (24 hours). */
|
||||
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
|
||||
|
||||
/** Default desktop client token lifetime in minutes (30 days). */
|
||||
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
|
||||
|
||||
/**
|
||||
* Default refresh grace period in minutes.
|
||||
*
|
||||
* <p>Allows refresh of expired tokens within this window after expiration.
|
||||
*/
|
||||
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Default allowed clock skew in seconds.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks during validation.
|
||||
*/
|
||||
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
/** Milliseconds per minute. */
|
||||
public static final long MILLIS_PER_MINUTE = 60_000L;
|
||||
|
||||
/** Seconds per minute. */
|
||||
public static final long SECONDS_PER_MINUTE = 60L;
|
||||
|
||||
/** JWT issuer identifier. */
|
||||
public static final String ISSUER = "https://stirling.com";
|
||||
|
||||
/**
|
||||
* Maximum refresh attempts allowed within the grace period window.
|
||||
*
|
||||
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
|
||||
*/
|
||||
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
@@ -393,12 +394,107 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token configuration.
|
||||
*
|
||||
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
|
||||
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
|
||||
* {@code tokenExpiryMinutes: 720} in your configuration.
|
||||
*/
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for web clients.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
|
||||
*
|
||||
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
|
||||
* defaults to 1440 minutes (24 hours).
|
||||
*/
|
||||
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for desktop clients (Tauri app).
|
||||
*
|
||||
* <p>Desktop clients are automatically detected via User-Agent header and receive
|
||||
* longer-lived tokens because they run on personal devices with OS-level encrypted
|
||||
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
|
||||
*
|
||||
* <p>This provides better UX (login once per month) while maintaining security through
|
||||
* device encryption and secure storage, matching the behavior of popular desktop apps
|
||||
* like Slack, Discord, VS Code, etc.
|
||||
*
|
||||
* <p>Default: 43200 minutes (30 days).
|
||||
*/
|
||||
private int desktopTokenExpiryMinutes = 43200;
|
||||
|
||||
/**
|
||||
* Allowed clock skew in seconds for JWT validation.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks. Tokens that are
|
||||
* slightly expired or slightly in the future (within this window) will still be
|
||||
* accepted.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
|
||||
*/
|
||||
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
|
||||
/**
|
||||
* Grace period in minutes for refreshing expired tokens.
|
||||
*
|
||||
* <p>Allows token refresh using an expired access token if the token expired within
|
||||
* this many minutes. This provides better UX by allowing users to refresh slightly
|
||||
* expired tokens without re-authentication.
|
||||
*
|
||||
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
|
||||
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
|
||||
*/
|
||||
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
|
||||
/**
|
||||
* Calculate number of days to retain old JWT signing keys.
|
||||
*
|
||||
* <p>Automatically calculated based on the longest token lifetime plus a proportional
|
||||
* safety buffer. Keys must be retained for at least as long as the tokens they signed
|
||||
* remain valid, otherwise token verification will fail.
|
||||
*
|
||||
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
|
||||
*
|
||||
* <p>The buffer includes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>10% of token lifetime (scales with token duration)
|
||||
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
|
||||
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
|
||||
* </ul>
|
||||
*
|
||||
* @return calculated key retention period in days
|
||||
*/
|
||||
public int getKeyRetentionDays() {
|
||||
final int MINUTES_PER_DAY = 1440;
|
||||
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
|
||||
|
||||
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
|
||||
|
||||
// Add 10% buffer (scales with token lifetime)
|
||||
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
|
||||
|
||||
// Add refresh grace period
|
||||
bufferMinutes += refreshGraceMinutes;
|
||||
|
||||
// Add clock skew (convert seconds to minutes, round up)
|
||||
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
|
||||
|
||||
// Total retention in minutes, convert to days (round up)
|
||||
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
|
||||
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+209
-2
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -58,6 +59,7 @@ public class ConvertPdfJsonController {
|
||||
}
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
|
||||
logJsonResponse("pdf/text-editor", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
@@ -114,10 +116,11 @@ public class ConvertPdfJsonController {
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
log.debug("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
|
||||
byte[] jsonBytes =
|
||||
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
|
||||
logJsonResponse("pdf/text-editor/metadata", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
@@ -185,11 +188,33 @@ public class ConvertPdfJsonController {
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
|
||||
logJsonResponse("pdf/text-editor/page", jsonBytes);
|
||||
String docName = "page_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@GetMapping(value = "/pdf/text-editor/fonts/{jobId}/{pageNumber}")
|
||||
@Operation(
|
||||
summary = "Extract fonts used by a single cached page for text editor",
|
||||
description =
|
||||
"Retrieves the font payloads used by a single page from a previously cached PDF document."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user. Output:JSON")
|
||||
public ResponseEntity<byte[]> extractPageFonts(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractPageFonts(jobId, pageNumber);
|
||||
logJsonResponse("pdf/text-editor/fonts/page", jsonBytes);
|
||||
String docName = "page_fonts_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/pdf/text-editor/clear-cache/{jobId}",
|
||||
consumes = MediaType.ALL_VALUE)
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
@@ -219,6 +244,188 @@ public class ConvertPdfJsonController {
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
private void logJsonResponse(String label, byte[] jsonBytes) {
|
||||
if (jsonBytes == null) {
|
||||
log.warn("Returning {} JSON response: null bytes", label);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only perform expensive tail extraction if debug logging is enabled
|
||||
if (log.isDebugEnabled()) {
|
||||
int length = jsonBytes.length;
|
||||
boolean endsWithJson =
|
||||
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
|
||||
String tail = "";
|
||||
if (length > 0) {
|
||||
int start = Math.max(0, length - 64);
|
||||
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
|
||||
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
|
||||
}
|
||||
log.debug(
|
||||
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
|
||||
label,
|
||||
length,
|
||||
endsWithJson,
|
||||
tail);
|
||||
}
|
||||
|
||||
if (isPdfJsonDebugDumpEnabled()) {
|
||||
try {
|
||||
String tmpDir = System.getProperty("java.io.tmpdir");
|
||||
String customDir = System.getenv("SPDF_PDFJSON_DUMP_DIR");
|
||||
java.nio.file.Path dumpDir =
|
||||
customDir != null && !customDir.isBlank()
|
||||
? java.nio.file.Path.of(customDir)
|
||||
: java.nio.file.Path.of(tmpDir);
|
||||
java.nio.file.Path dumpPath =
|
||||
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
|
||||
java.nio.file.Files.write(dumpPath, jsonBytes);
|
||||
log.debug("PDF JSON debug dump ({}): {}", label, dumpPath);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (isPdfJsonRepeatScanEnabled()) {
|
||||
logRepeatedJsonStrings(label, jsonBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPdfJsonDebugDumpEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_DUMP");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.dump");
|
||||
}
|
||||
|
||||
private boolean isPdfJsonRepeatScanEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_REPEAT_SCAN");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.repeatScan");
|
||||
}
|
||||
|
||||
private void logRepeatedJsonStrings(String label, byte[] jsonBytes) {
|
||||
final int minLen = 12;
|
||||
final int maxLen = 200;
|
||||
final int maxUnique = 50000;
|
||||
java.util.Map<String, Integer> counts = new java.util.HashMap<>();
|
||||
boolean inString = false;
|
||||
boolean escape = false;
|
||||
boolean tooLong = false;
|
||||
StringBuilder current = new StringBuilder(64);
|
||||
boolean capped = false;
|
||||
|
||||
for (byte b : jsonBytes) {
|
||||
char ch = (char) (b & 0xFF);
|
||||
if (!inString) {
|
||||
if (ch == '"') {
|
||||
inString = true;
|
||||
escape = false;
|
||||
tooLong = false;
|
||||
current.setLength(0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escape) {
|
||||
escape = false;
|
||||
if (!tooLong && current.length() < maxLen) {
|
||||
current.append(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inString = false;
|
||||
if (!tooLong) {
|
||||
int len = current.length();
|
||||
if (len >= minLen && len <= maxLen) {
|
||||
String value = current.toString();
|
||||
if (!looksLikeBase64(value)) {
|
||||
if (!capped || counts.containsKey(value)) {
|
||||
counts.merge(value, 1, Integer::sum);
|
||||
if (!capped && counts.size() >= maxUnique) {
|
||||
capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!tooLong) {
|
||||
if (current.length() < maxLen) {
|
||||
current.append(ch);
|
||||
} else {
|
||||
tooLong = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<java.util.Map.Entry<String, Integer>> top =
|
||||
counts.entrySet().stream()
|
||||
.filter(e -> e.getValue() > 1)
|
||||
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
|
||||
.limit(20)
|
||||
.toList();
|
||||
|
||||
if (!top.isEmpty()) {
|
||||
String summary =
|
||||
top.stream()
|
||||
.map(
|
||||
e ->
|
||||
String.format(
|
||||
"\"%s\"(len=%d,count=%d)",
|
||||
truncateForLog(e.getKey()),
|
||||
e.getKey().length(),
|
||||
e.getValue()))
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.debug(
|
||||
"PDF JSON repeat scan ({}): top strings -> {}{}",
|
||||
label,
|
||||
summary,
|
||||
capped ? " (capped)" : "");
|
||||
} else {
|
||||
log.debug(
|
||||
"PDF JSON repeat scan ({}): no repeated strings found{}",
|
||||
label,
|
||||
capped ? " (capped)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksLikeBase64(String value) {
|
||||
if (value.length() < 32) {
|
||||
return false;
|
||||
}
|
||||
int base64Chars = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if ((c >= 'A' && c <= 'Z')
|
||||
|| (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '+'
|
||||
|| c == '/'
|
||||
|| c == '=') {
|
||||
base64Chars++;
|
||||
}
|
||||
}
|
||||
return base64Chars >= value.length() * 0.9;
|
||||
}
|
||||
|
||||
private String truncateForLog(String value) {
|
||||
int max = 64;
|
||||
if (value.length() <= max) {
|
||||
return value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
}
|
||||
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
|
||||
+737
-107
File diff suppressed because it is too large
Load Diff
@@ -37,23 +37,68 @@ import stirling.software.SPDF.model.json.PdfJsonStream;
|
||||
@Component
|
||||
public class PdfJsonCosMapper {
|
||||
|
||||
public enum SerializationContext {
|
||||
DEFAULT,
|
||||
ANNOTATION_RAW_DATA,
|
||||
FORM_FIELD_RAW_DATA,
|
||||
CONTENT_STREAMS_LIGHTWEIGHT,
|
||||
RESOURCES_LIGHTWEIGHT;
|
||||
|
||||
public boolean omitStreamData() {
|
||||
return this == CONTENT_STREAMS_LIGHTWEIGHT || this == RESOURCES_LIGHTWEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(
|
||||
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
stream.getCOSObject(),
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
return serializeStream(
|
||||
cosStream,
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream, SerializationContext context)
|
||||
throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
|
||||
return serializeStream(
|
||||
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream, SerializationContext context)
|
||||
throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(stream.getCOSObject(), context);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
|
||||
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
return serializeCosValue(
|
||||
base,
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
|
||||
throws IOException {
|
||||
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
|
||||
return serializeCosValue(
|
||||
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
|
||||
}
|
||||
|
||||
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
|
||||
@@ -165,8 +210,8 @@ public class PdfJsonCosMapper {
|
||||
return cosStream;
|
||||
}
|
||||
|
||||
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
|
||||
throws IOException {
|
||||
private PdfJsonCosValue serializeCosValue(
|
||||
COSBase base, Set<COSBase> visited, SerializationContext context) throws IOException {
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -220,21 +265,23 @@ public class PdfJsonCosMapper {
|
||||
if (base instanceof COSArray array) {
|
||||
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
|
||||
for (COSBase item : array) {
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited, context);
|
||||
items.add(serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSStream stream) {
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(
|
||||
serializeStream(stream, visited, context));
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSDictionary dictionary) {
|
||||
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
|
||||
for (COSName key : dictionary.keySet()) {
|
||||
PdfJsonCosValue serialized =
|
||||
serializeCosValue(dictionary.getDictionaryObject(key), visited);
|
||||
serializeCosValue(
|
||||
dictionary.getDictionaryObject(key), visited, context);
|
||||
entries.put(key.getName(), serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
|
||||
@@ -248,16 +295,23 @@ public class PdfJsonCosMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
|
||||
private PdfJsonStream serializeStream(
|
||||
COSStream cosStream, Set<COSBase> visited, SerializationContext context)
|
||||
throws IOException {
|
||||
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
|
||||
for (COSName key : cosStream.keySet()) {
|
||||
COSBase value = cosStream.getDictionaryObject(key);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited, context);
|
||||
if (serialized != null) {
|
||||
dictionary.put(key.getName(), serialized);
|
||||
}
|
||||
}
|
||||
|
||||
if (context != null && context.omitStreamData()) {
|
||||
log.debug("Omitting stream rawData during {} serialization", context);
|
||||
return PdfJsonStream.builder().dictionary(dictionary).rawData(null).build();
|
||||
}
|
||||
|
||||
String rawData = null;
|
||||
try (InputStream inputStream = cosStream.createRawInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
+2
-1
@@ -426,7 +426,8 @@ public class PdfJsonFallbackFontService {
|
||||
String normalized =
|
||||
WHITESPACE_PATTERN
|
||||
.matcher(
|
||||
PATTERN.matcher(originalFontName).replaceAll("") // Remove subset prefix
|
||||
PATTERN.matcher(originalFontName)
|
||||
.replaceAll("") // Remove subset prefix
|
||||
.toLowerCase())
|
||||
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
// "timesnewroman")
|
||||
|
||||
@@ -64,7 +64,10 @@ 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.
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
|
||||
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
|
||||
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
|
||||
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
|
||||
+10
-3
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
@Value("${security.jwt.keyRetentionDays}")
|
||||
private int keyRetentionDays;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
public CacheConfig(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
cacheManager.setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
|
||||
+2
-1
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
|
||||
+208
-10
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
@@ -34,12 +35,15 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
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;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@@ -55,7 +59,9 @@ public class AuthController {
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final MfaService mfaService;
|
||||
private final TotpService totpService;
|
||||
private final RefreshRateLimitService refreshRateLimitService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -171,16 +177,52 @@ public class AuthController {
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
// Detect desktop client and issue longer-lived tokens for better UX
|
||||
// Desktop apps run on personal devices with OS-level encryption (secure storage)
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest);
|
||||
String token;
|
||||
int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays();
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
|
||||
token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440,
|
||||
keyRetentionDays);
|
||||
} else {
|
||||
// Web: Use configured web expiry (default 24 hours)
|
||||
token = jwtService.generateToken(user.getUsername(), claims);
|
||||
int webExpiryMinutes =
|
||||
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
log.info(
|
||||
"Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d",
|
||||
username,
|
||||
webExpiryMinutes,
|
||||
webExpiryMinutes / 1440,
|
||||
keyRetentionDays);
|
||||
}
|
||||
|
||||
// Record successful login
|
||||
loginAttemptService.loginSucceeded(username);
|
||||
log.info("Login successful for user: {} from IP: {}", username, ip);
|
||||
log.info(
|
||||
"Login successful for user: {} from IP: {} (desktop: {})",
|
||||
username,
|
||||
ip,
|
||||
isDesktopClient);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
token,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
String username = request.getUsername();
|
||||
@@ -272,25 +314,92 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
// Generate token hash for rate limiting (avoid storing actual tokens)
|
||||
String tokenHash = generateTokenHash(token);
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
|
||||
if (!isRefreshWithinGrace(claims)) {
|
||||
log.warn("Token refresh rejected: token expired beyond configured grace window");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
// Only apply rate limiting if token is actually expired (not for valid tokens)
|
||||
// This prevents false-positive 429 errors with multiple tabs, retries, etc.
|
||||
long expMillis = extractEpochMillis(claims.get("exp"));
|
||||
boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis();
|
||||
if (isExpired
|
||||
&& !refreshRateLimitService.isRefreshAllowed(
|
||||
tokenHash, getRefreshGraceMillis())) {
|
||||
log.warn(
|
||||
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Too many refresh attempts",
|
||||
"max_attempts",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
|
||||
}
|
||||
|
||||
Object usernameClaim = claims.get("sub");
|
||||
String username = usernameClaim != null ? usernameClaim.toString() : null;
|
||||
if (username == null || username.isBlank()) {
|
||||
log.warn("Token refresh rejected: missing subject claim");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
Map<String, Object> newClaims = new HashMap<>();
|
||||
newClaims.put("authType", user.getAuthenticationType());
|
||||
newClaims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String newToken;
|
||||
if (isDesktopClient) {
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
|
||||
newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Refreshed DESKTOP token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
newToken = jwtService.generateToken(username, newClaims);
|
||||
int webExpiryMinutes =
|
||||
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
log.info(
|
||||
"Refreshed WEB token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
webExpiryMinutes,
|
||||
webExpiryMinutes / 1440);
|
||||
}
|
||||
|
||||
// Don't clear rate limit tracking - let it expire naturally after grace period
|
||||
// This prevents reusing the same expired token indefinitely
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
newToken,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("Token refresh failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -532,6 +641,95 @@ public class AuthController {
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes =
|
||||
configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds(boolean isDesktop) {
|
||||
if (isDesktop) {
|
||||
// Desktop: use configured desktop token expiry
|
||||
return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties)
|
||||
* JwtConstants.SECONDS_PER_MINUTE;
|
||||
}
|
||||
// Web: use configured web value
|
||||
return getTokenExpirySeconds();
|
||||
}
|
||||
|
||||
private boolean isRefreshWithinGrace(Map<String, Object> 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.
|
||||
*
|
||||
* <p>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)
|
||||
|
||||
+23
-3
@@ -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<String, Object> 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 =
|
||||
|
||||
+22
-4
@@ -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<String, Object> 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 =
|
||||
|
||||
+137
-23
@@ -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<String, Object> claims, int expiryMinutes) {
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
Optional<KeyPair> 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<String, Object> extractClaims(String token) {
|
||||
Claims claims = extractAllClaims(token);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> 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> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
final Claims claims = extractAllClaims(token, false);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private <T> T extractClaim(
|
||||
String token, Function<Claims, T> 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.
|
||||
*
|
||||
* <p>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<String, Object> header =
|
||||
OBJECT_MAPPER.readValue(
|
||||
headerBytes, new TypeReference<Map<String, Object>>() {});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String generateToken(String username, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> extractClaimsAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*
|
||||
|
||||
+191
-13
@@ -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.
|
||||
*
|
||||
* <p>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<Path> 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.
|
||||
*
|
||||
* <p>Private key stored as: keyId.key
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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());
|
||||
}
|
||||
|
||||
+124
@@ -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.
|
||||
*
|
||||
* <p>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<String, RefreshAttempt> 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<String, Object> getStats() {
|
||||
return Map.of(
|
||||
"tracked_tokens",
|
||||
attempts.size(),
|
||||
"max_attempts_allowed",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
|
||||
}
|
||||
}
|
||||
+82
@@ -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.
|
||||
*
|
||||
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
|
||||
*
|
||||
* <ul>
|
||||
* <li>They run on personal devices (not shared computers)
|
||||
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
|
||||
* <li>Better UX (users expect desktop apps to stay logged in)
|
||||
* </ul>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
+86
-3
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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
|
||||
|
||||
+7
-1
@@ -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);
|
||||
|
||||
+3
-15
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
@@ -1427,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"
|
||||
@@ -1463,6 +1478,7 @@ notesStamps = "Notes & Stamps"
|
||||
opacity = "Opacity"
|
||||
pen = "Pen"
|
||||
polygon = "Polygon"
|
||||
properties = "Properties"
|
||||
rectangle = "Rectangle"
|
||||
redo = "Redo"
|
||||
saveChanges = "Save Changes"
|
||||
@@ -1488,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)"
|
||||
@@ -4218,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)"
|
||||
@@ -5298,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"
|
||||
@@ -6167,11 +6202,6 @@ title = "API Documentation"
|
||||
[tableExtraxt]
|
||||
tags = "CSV,Table Extraction,extract,convert"
|
||||
|
||||
[textAlign]
|
||||
center = "Center"
|
||||
left = "Left"
|
||||
right = "Right"
|
||||
|
||||
[theme]
|
||||
toggle = "Toggle Theme"
|
||||
|
||||
@@ -6447,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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
|
||||
<Popover.Target>
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() => 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)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorSwatch color={value} size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
swatches={[
|
||||
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
|
||||
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
|
||||
]}
|
||||
swatchesPerRow={5}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t('annotation.opacity', 'Opacity')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() => 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)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<OpacityIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs" style={{ minWidth: 150 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t('annotation.opacity', 'Opacity')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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<string, any>) => 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 = () => (
|
||||
<Stack gap="md" style={{ minWidth: 280 }}>
|
||||
{/* Font Size */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.fontSize', 'Font size')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={fontSize}
|
||||
onChange={(val) => onUpdate({ fontSize: val })}
|
||||
min={8}
|
||||
max={32}
|
||||
label={(val) => `${val}pt`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Opacity */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.opacity', 'Opacity')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={Math.round((obj?.opacity ?? 1) * 100)}
|
||||
onChange={(val) => onUpdate({ opacity: val / 100 })}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Alignment */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.textAlignment', 'Text Alignment')}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'left' ? 'filled' : 'default'}
|
||||
onClick={() => onUpdate({ textAlign: 0 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'center' ? 'filled' : 'default'}
|
||||
onClick={() => onUpdate({ textAlign: 1 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'right' ? 'filled' : 'default'}
|
||||
onClick={() => onUpdate({ textAlign: 2 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignRightIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderShapeControls = () => (
|
||||
<Stack gap="md" style={{ minWidth: 250 }}>
|
||||
{/* Opacity */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.opacity', 'Opacity')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={opacity}
|
||||
onChange={(val) => {
|
||||
const newOpacity = val / 100;
|
||||
onUpdate({
|
||||
opacity: newOpacity,
|
||||
strokeOpacity: newOpacity,
|
||||
fillOpacity: newOpacity,
|
||||
});
|
||||
}}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stroke Width */}
|
||||
<div>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.strokeWidth', 'Stroke')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={strokeWidth}
|
||||
onChange={(val) => {
|
||||
onUpdate({
|
||||
borderWidth: val,
|
||||
strokeWidth: val,
|
||||
lineWidth: val,
|
||||
});
|
||||
}}
|
||||
min={0}
|
||||
max={12}
|
||||
label={(val) => `${val}pt`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={!borderVisible ? 'filled' : 'light'}
|
||||
onClick={() => {
|
||||
const newValue = borderVisible ? 0 : 1;
|
||||
onUpdate({
|
||||
borderWidth: newValue,
|
||||
strokeWidth: newValue,
|
||||
lineWidth: newValue,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{borderVisible
|
||||
? t('annotation.borderOn', 'Border: On')
|
||||
: t('annotation.borderOff', 'Border: Off')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t('annotation.properties', 'Properties')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() => 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)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuneIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
|
||||
{annotationType === 'shape' && renderShapeControls()}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t('annotation.width', 'Width')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() => 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)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LineWeightIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs" style={{ minWidth: 150 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t('annotation.width', 'Width')}
|
||||
</Text>
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={min}
|
||||
max={max}
|
||||
label={(val) => `${val}pt`}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AnnotationSelectionMenuInner
|
||||
documentId={activeDocumentId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<string, any>) => {
|
||||
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 = () => (
|
||||
<Tooltip label={t('annotation.editText', 'Edit Text')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={handleOpenTextEditor}
|
||||
styles={commonButtonStyles}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const DeleteButton = () => (
|
||||
<Tooltip label={t('annotation.delete', 'Delete')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="md"
|
||||
onClick={handleDelete}
|
||||
styles={{
|
||||
root: {
|
||||
...commonButtonStyles.root,
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-red-1)',
|
||||
borderColor: 'var(--mantine-color-red-4)',
|
||||
color: 'var(--mantine-color-red-7)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
switch (annotationType) {
|
||||
case 'textMarkup':
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getCurrentColor()}
|
||||
onChange={(color) => handleColorChange(color, 'main')}
|
||||
label={t('annotation.changeColor', 'Change Colour')}
|
||||
/>
|
||||
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
|
||||
case 'ink':
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getCurrentColor()}
|
||||
onChange={(color) => handleColorChange(color, 'main')}
|
||||
label={t('annotation.changeColor', 'Change Colour')}
|
||||
/>
|
||||
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
|
||||
case 'inkHighlighter':
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getCurrentColor()}
|
||||
onChange={(color) => handleColorChange(color, 'main')}
|
||||
label={t('annotation.changeColor', 'Change Colour')}
|
||||
/>
|
||||
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={20} />
|
||||
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
case 'note':
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getTextColor()}
|
||||
onChange={(color) => handleColorChange(color, 'text')}
|
||||
label={t('annotation.color', 'Color')}
|
||||
/>
|
||||
<ColorControl
|
||||
value={getBackgroundColor()}
|
||||
onChange={(color) => handleColorChange(color, 'background')}
|
||||
label={t('annotation.backgroundColor', 'Background color')}
|
||||
/>
|
||||
<EditTextButton />
|
||||
<PropertiesPopover
|
||||
annotationType={annotationType}
|
||||
annotation={annotation}
|
||||
onUpdate={handlePropertiesUpdate}
|
||||
/>
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
|
||||
case 'shape':
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getStrokeColor()}
|
||||
onChange={(color) => handleColorChange(color, 'stroke')}
|
||||
label={t('annotation.strokeColor', 'Stroke Colour')}
|
||||
/>
|
||||
<ColorControl
|
||||
value={getFillColor()}
|
||||
onChange={(color) => handleColorChange(color, 'fill')}
|
||||
label={t('annotation.fillColor', 'Fill Colour')}
|
||||
/>
|
||||
<PropertiesPopover
|
||||
annotationType="shape"
|
||||
annotation={annotation}
|
||||
onUpdate={handlePropertiesUpdate}
|
||||
/>
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
|
||||
case 'line':
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getCurrentColor()}
|
||||
onChange={(color) => handleColorChange(color, 'main')}
|
||||
label={t('annotation.changeColor', 'Change Colour')}
|
||||
/>
|
||||
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
|
||||
case 'stamp':
|
||||
return <DeleteButton />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<ColorControl
|
||||
value={getCurrentColor()}
|
||||
onChange={(color) => handleColorChange(color, 'main')}
|
||||
label={t('annotation.changeColor', 'Change Colour')}
|
||||
/>
|
||||
<DeleteButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 10000, // Very high z-index to appear above everything
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
fontSize: '14px',
|
||||
minWidth: `${calculateWidth(annotationType)}px`,
|
||||
transition: 'min-width 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" justify="center">
|
||||
{renderButtons()}
|
||||
</Group>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${textBoxPosition.top}px`,
|
||||
left: `${textBoxPosition.left}px`,
|
||||
width: `${textBoxPosition.width}px`,
|
||||
height: `${textBoxPosition.height}px`,
|
||||
zIndex: 10001,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textDraft}
|
||||
onChange={(e) => setTextDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCloseTextEdit();
|
||||
} else if (e.key === 'Enter' && e.ctrlKey) {
|
||||
handleSaveText();
|
||||
}
|
||||
}}
|
||||
onBlur={handleSaveText}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '0',
|
||||
minWidth: '0',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
fontSize: `${textBoxPosition.fontSize}px`,
|
||||
fontFamily: textBoxPosition.fontFamily,
|
||||
lineHeight: '1.2',
|
||||
color: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '2px solid var(--mantine-color-blue-5)',
|
||||
borderRadius: '0',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
resize: 'none',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */}
|
||||
<div
|
||||
ref={setRef}
|
||||
onClick={canClickToEdit ? handleOpenTextEditor : undefined}
|
||||
style={{
|
||||
// Use EmbedPDF's positioning styles
|
||||
...menuWrapperProps?.style,
|
||||
// Keep the wrapper invisible but still occupying space for positioning
|
||||
opacity: 0,
|
||||
pointerEvents: canClickToEdit ? 'auto' : 'none',
|
||||
}}
|
||||
/>
|
||||
{typeof document !== 'undefined' && menuContent
|
||||
? createPortal(menuContent, document.body)
|
||||
: null}
|
||||
{typeof document !== 'undefined' && textEditorOverlay
|
||||
? createPortal(textEditorOverlay, document.body)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => <RedactionSelectionMenu {...props} />}
|
||||
selectionMenu={(props) => <AnnotationSelectionMenu {...props} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import FileManager from "@app/components/FileManager";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
@@ -60,22 +61,37 @@ export default function HomePage() {
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
const prevFileCountRef = useRef(activeFiles.length);
|
||||
|
||||
// Auto-switch to viewer when going from 0 to 1 file
|
||||
// Skip this if PDF Text Editor is active - it handles its own empty state
|
||||
// Startup/open transition behavior:
|
||||
// - opening exactly 1 file from empty -> viewer (unless already in fileEditor)
|
||||
// - opening 2+ files from empty -> fileEditor
|
||||
useEffect(() => {
|
||||
const prevCount = prevFileCountRef.current;
|
||||
const currentCount = activeFiles.length;
|
||||
|
||||
if (
|
||||
navigationState.workbench !== 'fileEditor' &&
|
||||
prevCount === 0 &&
|
||||
currentCount === 1
|
||||
) {
|
||||
// PDF Text Editor handles its own empty state with a dropzone
|
||||
if (selectedToolKey !== 'pdfTextEditor') {
|
||||
actions.setWorkbench('viewer');
|
||||
setActiveFileIndex(0);
|
||||
console.log('[HomePage] Navigation effect triggered:', {
|
||||
prevCount,
|
||||
currentCount,
|
||||
currentWorkbench: navigationState.workbench,
|
||||
selectedToolKey,
|
||||
});
|
||||
|
||||
const action = getStartupNavigationAction(
|
||||
prevCount,
|
||||
currentCount,
|
||||
selectedToolKey,
|
||||
navigationState.workbench
|
||||
);
|
||||
|
||||
console.log('[HomePage] Navigation action returned:', action);
|
||||
|
||||
if (action) {
|
||||
console.log('[HomePage] Applying navigation:', action);
|
||||
actions.setWorkbench(action.workbench);
|
||||
if (typeof action.activeFileIndex === 'number') {
|
||||
setActiveFileIndex(action.activeFileIndex);
|
||||
}
|
||||
} else {
|
||||
console.log('[HomePage] No navigation - staying in current workbench');
|
||||
}
|
||||
|
||||
prevFileCountRef.current = currentCount;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -372,7 +372,6 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
activeToolRef,
|
||||
manualToolSwitch,
|
||||
setActiveTool,
|
||||
setSelectedTextDraft,
|
||||
setSelectedFontSize,
|
||||
|
||||
@@ -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<ColorTarget>(null);
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | 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) {
|
||||
</Paper>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>{t('annotation.editTextMarkup', 'Edit Text Markup')}</Text>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedAnn.object?.color ?? highlightColor}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('highlight');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
|
||||
onChange={(value) => {
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ opacity: value / 100 }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{isHighlighter ? t('annotation.freehandHighlighter', 'Freehand Highlighter') : t('annotation.editInk', 'Edit Pen')}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={colorValue}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget(isHighlighter ? 'highlight' : 'ink');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{isHighlighter && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={opacityValue}
|
||||
onChange={(value) => {
|
||||
setHighlightOpacity(value);
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ opacity: value / 100 }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={isHighlighter ? 20 : 12}
|
||||
value={thicknessValue}
|
||||
onChange={(value) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>{isNote ? t('annotation.editNote', 'Edit Sticky Note') : t('annotation.editText', 'Edit Text Box')}</Text>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedAnn.object?.textColor ?? selectedAnn.object?.color ?? textColor}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('text');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.backgroundColor', 'Background color')}</Text>
|
||||
<Group gap="xs" align="center">
|
||||
<ColorSwatchButton
|
||||
color={selectedBackground}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget(isNote ? 'noteBackground' : 'textBackground');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={selectedAnn.object?.backgroundColor ? 'light' : 'default'}
|
||||
onClick={() => {
|
||||
if (isNote) {
|
||||
setNoteBackgroundColor('');
|
||||
} else {
|
||||
setTextBackgroundColor('');
|
||||
}
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ backgroundColor: 'transparent', fillColor: 'transparent' }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('annotation.clearBackground', 'Remove background')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
<Textarea
|
||||
label={t('annotation.text', 'Text')}
|
||||
value={selectedTextDraft}
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget;
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
const val = selectedTextDraft;
|
||||
const newVal = val.substring(0, start) + '\r\n' + val.substring(end);
|
||||
setSelectedTextDraft(newVal);
|
||||
setTimeout(() => {
|
||||
target.selectionStart = target.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
if (selectedUpdateTimer.current) {
|
||||
clearTimeout(selectedUpdateTimer.current);
|
||||
}
|
||||
selectedUpdateTimer.current = setTimeout(() => {
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ contents: newVal, textColor: selectedAnn.object?.textColor ?? textColor }
|
||||
);
|
||||
}, 120);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
setSelectedTextDraft(val);
|
||||
if (selectedUpdateTimer.current) {
|
||||
clearTimeout(selectedUpdateTimer.current);
|
||||
}
|
||||
selectedUpdateTimer.current = setTimeout(() => {
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ contents: val, textColor: selectedAnn.object?.textColor ?? textColor }
|
||||
);
|
||||
}, 120);
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.fontSize', 'Font size')}</Text>
|
||||
<Slider
|
||||
min={8}
|
||||
max={32}
|
||||
value={selectedFontSize}
|
||||
onChange={(size) => {
|
||||
setSelectedFontSize(size);
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ fontSize: size }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.textAlignment', 'Text Alignment')}</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'left' ? 'filled' : 'default'}
|
||||
onClick={() => {
|
||||
setTextAlignment('left');
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ textAlign: 0 }
|
||||
);
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-left" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'center' ? 'filled' : 'default'}
|
||||
onClick={() => {
|
||||
setTextAlignment('center');
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ textAlign: 1 }
|
||||
);
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-center" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'right' ? 'filled' : 'default'}
|
||||
onClick={() => {
|
||||
setTextAlignment('right');
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ textAlign: 2 }
|
||||
);
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
<LocalIcon icon="format-align-right" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 13 || toolId === 'stamp') {
|
||||
const imageSrc = selectedAnn.object?.imageSrc || selectedAnn.object?.data || selectedAnn.object?.url;
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>{t('annotation.stamp', 'Add Image')}</Text>
|
||||
{imageSrc ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">{t('annotation.imagePreview', 'Preview')}</Text>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={t('annotation.stamp', 'Add Image')}
|
||||
style={{ maxWidth: '100%', maxHeight: '180px', objectFit: 'contain', border: '1px solid #ccc', borderRadius: '4px' }}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('annotation.editStampHint', 'To change the image, delete this stamp and add a new one.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if ((type !== undefined && [4, 8].includes(type)) || toolId === 'line' || toolId === 'polyline') {
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>{t('annotation.editLine', 'Edit Line')}</Text>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedAnn.object?.strokeColor ?? shapeStrokeColor}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeStroke');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
|
||||
onChange={(value) => {
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ opacity: value / 100 }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={12}
|
||||
value={selectedAnn.object?.borderWidth ?? shapeThickness}
|
||||
onChange={(value) => {
|
||||
annotationApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{
|
||||
borderWidth: value,
|
||||
strokeWidth: value,
|
||||
lineWidth: value,
|
||||
}
|
||||
);
|
||||
setShapeThickness(value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>{t(`annotation.edit${shapeName}`, `Edit ${shapeName}`)}</Text>
|
||||
<Group gap="md">
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">{t('annotation.strokeColor', 'Stroke Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={strokeColorValue}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeStroke');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" c="dimmed">{t('annotation.fillColor', 'Fill Color')}</Text>
|
||||
<ColorSwatchButton
|
||||
color={fillColorValue}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeFill');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={opacityValue}
|
||||
onChange={(value) => {
|
||||
setShapeOpacity(value);
|
||||
setShapeStrokeOpacity(value);
|
||||
setShapeFillOpacity(value);
|
||||
if (annId) {
|
||||
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
|
||||
opacity: value / 100,
|
||||
strokeOpacity: value / 100,
|
||||
fillOpacity: value / 100,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Stroke')}</Text>
|
||||
<Slider
|
||||
min={0}
|
||||
max={12}
|
||||
value={selectedAnn.object?.borderWidth ?? shapeThickness}
|
||||
onChange={(value) => {
|
||||
if (annId) {
|
||||
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
|
||||
borderWidth: value,
|
||||
strokeWidth: value,
|
||||
lineWidth: value,
|
||||
});
|
||||
}
|
||||
setShapeThickness(value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={(selectedAnn.object?.borderWidth ?? shapeThickness) === 0 ? 'filled' : 'light'}
|
||||
onClick={() => {
|
||||
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')
|
||||
}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>{t('annotation.editSelected', 'Edit Annotation')}</Text>
|
||||
<Text size="xs" c="dimmed">{t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})();
|
||||
|
||||
const colorPickerComponent = (
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
@@ -1287,11 +776,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
{renderToolButtons(otherTools)}
|
||||
</Box>
|
||||
|
||||
{activeTool !== 'select' && defaultStyleControls}
|
||||
|
||||
{activeTool === 'select' && selectedAnn && selectedAnnotationControls}
|
||||
|
||||
{activeTool === 'select' && !selectedAnn && defaultStyleControls}
|
||||
{activeTool === 'stamp' && defaultStyleControls}
|
||||
|
||||
{colorPickerComponent}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ interface UseAnnotationSelectionParams {
|
||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
|
||||
activeToolRef: React.MutableRefObject<AnnotationToolId>;
|
||||
manualToolSwitch: React.MutableRefObject<boolean>;
|
||||
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');
|
||||
|
||||
@@ -16,6 +16,7 @@ import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { Util } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import {
|
||||
PdfJsonDocument,
|
||||
PdfJsonFont,
|
||||
PdfJsonImageElement,
|
||||
PdfJsonPage,
|
||||
TextGroup,
|
||||
@@ -450,14 +451,25 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
},
|
||||
);
|
||||
const [pageResponse, pageFontsResponse] = await Promise.all([
|
||||
apiClient.get(
|
||||
`/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
},
|
||||
),
|
||||
apiClient.get(
|
||||
`/api/v1/convert/pdf/text-editor/fonts/${cachedJobId}/${pageNumber}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const pageData = response.data as PdfJsonPage;
|
||||
const pageData = pageResponse.data as PdfJsonPage;
|
||||
const pageFonts = Array.isArray(pageFontsResponse.data)
|
||||
? (pageFontsResponse.data as PdfJsonFont[])
|
||||
: [];
|
||||
const normalizedImages = (pageData.imageElements ?? []).map(cloneImageElement);
|
||||
|
||||
if (imagesByPageRef.current.length <= pageIndex) {
|
||||
@@ -471,12 +483,31 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}
|
||||
const nextPages = [...prevDoc.pages];
|
||||
const existingPage = nextPages[pageIndex] ?? {};
|
||||
const fontMap = new Map<string, PdfJsonFont>();
|
||||
for (const existingFont of prevDoc.fonts ?? []) {
|
||||
if (!existingFont) {
|
||||
continue;
|
||||
}
|
||||
const existingKey = existingFont.uid || `${existingFont.pageNumber ?? -1}:${existingFont.id ?? ''}`;
|
||||
fontMap.set(existingKey, existingFont);
|
||||
}
|
||||
if (pageFonts.length > 0) {
|
||||
for (const font of pageFonts) {
|
||||
if (!font) {
|
||||
continue;
|
||||
}
|
||||
const key = font.uid || `${font.pageNumber ?? -1}:${font.id ?? ''}`;
|
||||
fontMap.set(key, font);
|
||||
}
|
||||
}
|
||||
const nextFonts = Array.from(fontMap.values());
|
||||
nextPages[pageIndex] = {
|
||||
...existingPage,
|
||||
imageElements: normalizedImages.map(cloneImageElement),
|
||||
};
|
||||
return {
|
||||
...prevDoc,
|
||||
fonts: nextFonts,
|
||||
pages: nextPages,
|
||||
};
|
||||
});
|
||||
@@ -1087,8 +1118,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const canUseIncremental =
|
||||
isLazyMode &&
|
||||
cachedJobId &&
|
||||
dirtyPageIndices.length > 0 &&
|
||||
dirtyPageIndices.length < totalPages;
|
||||
dirtyPageIndices.length > 0;
|
||||
|
||||
if (canUseIncremental) {
|
||||
await ensureImagesForPages(dirtyPageIndices);
|
||||
@@ -1105,10 +1135,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
|
||||
|
||||
const partialDocument: PdfJsonDocument = {
|
||||
metadata: document.metadata,
|
||||
xmpMetadata: document.xmpMetadata,
|
||||
fonts: document.fonts,
|
||||
lazyImages: true,
|
||||
// Incremental export only needs changed pages.
|
||||
// Fonts/resources/content streams are resolved from server-side cache.
|
||||
pages: partialPages,
|
||||
};
|
||||
|
||||
@@ -1135,11 +1163,13 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
} catch (incrementalError) {
|
||||
if (isLazyMode && cachedJobIdRef.current) {
|
||||
throw new Error('Incremental export failed for cached document. Please reload and retry.');
|
||||
}
|
||||
console.warn(
|
||||
'[handleGeneratePdf] Incremental export failed, falling back to full export',
|
||||
incrementalError,
|
||||
);
|
||||
// Fall through to full export below
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,8 +1302,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const canUseIncremental =
|
||||
isLazyMode &&
|
||||
cachedJobId &&
|
||||
dirtyPageIndices.length > 0 &&
|
||||
dirtyPageIndices.length < totalPages;
|
||||
dirtyPageIndices.length > 0;
|
||||
|
||||
if (canUseIncremental) {
|
||||
await ensureImagesForPages(dirtyPageIndices);
|
||||
@@ -1290,10 +1319,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
|
||||
|
||||
const partialDocument: PdfJsonDocument = {
|
||||
metadata: document.metadata,
|
||||
xmpMetadata: document.xmpMetadata,
|
||||
fonts: document.fonts,
|
||||
lazyImages: true,
|
||||
// Incremental export only needs changed pages.
|
||||
// Fonts/resources/content streams are resolved from server-side cache.
|
||||
pages: partialPages,
|
||||
};
|
||||
|
||||
@@ -1312,6 +1339,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
downloadName = detectedName || expectedName;
|
||||
pdfBlob = response.data;
|
||||
} catch (incrementalError) {
|
||||
if (isLazyMode && cachedJobId) {
|
||||
throw new Error('Incremental export failed for cached document. Please reload and retry.');
|
||||
}
|
||||
console.warn(
|
||||
'[handleSaveToWorkbench] Incremental export failed, falling back to full export',
|
||||
incrementalError,
|
||||
|
||||
@@ -1209,7 +1209,7 @@ export const buildUpdatedDocument = (
|
||||
...page,
|
||||
textElements: updatedElements,
|
||||
imageElements: images.map(cloneImageElement),
|
||||
contentStreams: page.contentStreams ?? [],
|
||||
contentStreams: page.contentStreams ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1282,7 +1282,7 @@ export const restoreGlyphElements = (
|
||||
...page,
|
||||
textElements: rebuiltElements,
|
||||
imageElements: images.map(cloneImageElement),
|
||||
contentStreams: page.contentStreams ?? [],
|
||||
contentStreams: page.contentStreams ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export type BaseWorkbenchType = typeof BASE_WORKBENCH_TYPES[number];
|
||||
// Workbench types including custom views
|
||||
export type WorkbenchType = BaseWorkbenchType | `custom:${string}`;
|
||||
|
||||
export const getDefaultWorkbench = (): WorkbenchType => 'fileEditor';
|
||||
export const getDefaultWorkbench = (): WorkbenchType => 'viewer';
|
||||
|
||||
// Type guard using the same source of truth
|
||||
export const isValidWorkbench = (value: string): value is WorkbenchType => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getStartupNavigationAction } from '@app/utils/homePageNavigation';
|
||||
import type { WorkbenchType } from '@app/types/workbench';
|
||||
|
||||
describe('getStartupNavigationAction', () => {
|
||||
it('returns viewer + active index for 0->1 transition when not in fileEditor', () => {
|
||||
expect(getStartupNavigationAction(0, 1, null, 'viewer' as WorkbenchType)).toEqual({
|
||||
workbench: 'viewer',
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(getStartupNavigationAction(0, 1, null, 'pageEditor' as WorkbenchType)).toEqual({
|
||||
workbench: 'viewer',
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fileEditor for 0->2+ transition when not in fileEditor', () => {
|
||||
expect(getStartupNavigationAction(0, 2, null, 'viewer' as WorkbenchType)).toEqual({
|
||||
workbench: 'fileEditor',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not force navigation for pdfTextEditor', () => {
|
||||
expect(getStartupNavigationAction(0, 1, 'pdfTextEditor', 'viewer' as WorkbenchType)).toBeNull();
|
||||
expect(getStartupNavigationAction(0, 3, 'pdfTextEditor', 'viewer' as WorkbenchType)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not navigate on non-startup transitions', () => {
|
||||
expect(getStartupNavigationAction(1, 2, null, 'viewer' as WorkbenchType)).toBeNull();
|
||||
expect(getStartupNavigationAction(2, 1, null, 'viewer' as WorkbenchType)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not navigate when user already has files (N→M transitions)', () => {
|
||||
// User has 1 file, adds another -> no navigation (stay in current workbench)
|
||||
expect(getStartupNavigationAction(1, 2, null, 'viewer' as WorkbenchType)).toBeNull();
|
||||
expect(getStartupNavigationAction(1, 2, null, 'fileEditor' as WorkbenchType)).toBeNull();
|
||||
|
||||
// User has 3 files, adds more -> no navigation
|
||||
expect(getStartupNavigationAction(3, 4, null, 'fileEditor' as WorkbenchType)).toBeNull();
|
||||
|
||||
// User has 2 files, deletes 1 -> no navigation
|
||||
expect(getStartupNavigationAction(2, 1, null, 'viewer' as WorkbenchType)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles all workbench types consistently for 0→N transitions', () => {
|
||||
// 0→1 always goes to viewer regardless of current workbench (since default is viewer)
|
||||
expect(getStartupNavigationAction(0, 1, null, 'viewer' as WorkbenchType)).toEqual({
|
||||
workbench: 'viewer',
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(getStartupNavigationAction(0, 1, null, 'fileEditor' as WorkbenchType)).toEqual({
|
||||
workbench: 'viewer',
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(getStartupNavigationAction(0, 1, null, 'pageEditor' as WorkbenchType)).toEqual({
|
||||
workbench: 'viewer',
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
expect(getStartupNavigationAction(0, 1, null, 'custom:formFill' as WorkbenchType)).toEqual({
|
||||
workbench: 'viewer',
|
||||
activeFileIndex: 0,
|
||||
});
|
||||
|
||||
// 0→N (N>1) always goes to fileEditor
|
||||
expect(getStartupNavigationAction(0, 3, null, 'viewer' as WorkbenchType)).toEqual({
|
||||
workbench: 'fileEditor',
|
||||
});
|
||||
expect(getStartupNavigationAction(0, 3, null, 'custom:myTool' as WorkbenchType)).toEqual({
|
||||
workbench: 'fileEditor',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { WorkbenchType } from '@app/types/workbench';
|
||||
|
||||
export type StartupWorkbench = 'viewer' | 'fileEditor';
|
||||
|
||||
export interface StartupNavigationAction {
|
||||
workbench: StartupWorkbench;
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
export function getStartupNavigationAction(
|
||||
previousFileCount: number,
|
||||
currentFileCount: number,
|
||||
selectedToolKey: string | null,
|
||||
currentWorkbench: WorkbenchType
|
||||
): StartupNavigationAction | null {
|
||||
console.log('[homePageNavigation] Called with:', {
|
||||
previousFileCount,
|
||||
currentFileCount,
|
||||
selectedToolKey,
|
||||
currentWorkbench,
|
||||
});
|
||||
|
||||
// pdfTextEditor handles its own empty state
|
||||
if (selectedToolKey === 'pdfTextEditor') {
|
||||
console.log('[homePageNavigation] pdfTextEditor detected, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only handle transitions from empty (0 files) to some files
|
||||
if (previousFileCount !== 0) {
|
||||
console.log('[homePageNavigation] Not a 0→N transition, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 0→1: Go to viewer to view the single file
|
||||
if (currentFileCount === 1) {
|
||||
console.log('[homePageNavigation] 0→1 transition, returning viewer');
|
||||
return { workbench: 'viewer', activeFileIndex: 0 };
|
||||
}
|
||||
|
||||
// 0→N (N>1): Go to fileEditor to manage multiple files
|
||||
if (currentFileCount > 1) {
|
||||
console.log('[homePageNavigation] 0→N transition, returning fileEditor');
|
||||
return { workbench: 'fileEditor' };
|
||||
}
|
||||
|
||||
console.log('[homePageNavigation] Still at 0 files, returning null');
|
||||
return null;
|
||||
}
|
||||
@@ -227,6 +227,10 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ 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 })}
|
||||
|
||||
@@ -13,7 +13,17 @@ export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
|
||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<PlatformSessionUser | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
try {
|
||||
await authService.saveToken(token);
|
||||
} catch (error) {
|
||||
console.error('[PlatformBridge] Failed to save token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<boolean> | 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<void> {
|
||||
private async saveTokenEverywhere(
|
||||
token: string,
|
||||
refreshToken?: string | null,
|
||||
emitJwtAvailable = true
|
||||
): Promise<void> {
|
||||
// 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<string | null>('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<string | null> {
|
||||
const token = await invoke<string | null>('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<string | null>('get_refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,19 +131,16 @@ export class AuthService {
|
||||
private async clearTokenEverywhere(): Promise<void> {
|
||||
// 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<UserInfo> {
|
||||
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<LoginResponse>('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<void> {
|
||||
await this.saveTokenEverywhere(token, undefined, false);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
// 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();
|
||||
|
||||
@@ -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<string | null> {
|
||||
// Check localStorage first (web layer token)
|
||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||
if (localStorageToken) {
|
||||
return localStorageToken;
|
||||
}
|
||||
|
||||
// Fallback to Tauri store
|
||||
try {
|
||||
return await invoke<string | null>('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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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() {
|
||||
|
||||
+70
-9
@@ -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() {
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_keyRetentionDays"
|
||||
name="jwt_tokenExpiryMinutes"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
|
||||
<span>{t('admin.settings.security.jwt.tokenExpiryMinutes.label', 'Web Token Expiry (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.tokenExpiryMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_desktopTokenExpiryMinutes"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.desktopTokenExpiryMinutes.label', 'Desktop Token Expiry (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.desktopTokenExpiryMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_allowedClockSkewSeconds"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.allowedClockSkewSeconds.label', 'Clock Skew Tolerance (seconds)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.allowedClockSkewSeconds')} />
|
||||
</Group>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_refreshGraceMinutes"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.refreshGraceMinutes.label', 'Refresh Grace Period (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.refreshGraceMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface PlatformSessionUser {
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no desktop SaaS auth bridge.
|
||||
*/
|
||||
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform user store.
|
||||
*/
|
||||
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform refresh path.
|
||||
*/
|
||||
export async function refreshPlatformSession(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform-specific token storage (uses localStorage only).
|
||||
*/
|
||||
export async function savePlatformToken(_token: string): Promise<void> {
|
||||
// Web mode: token already saved to localStorage in springAuthClient
|
||||
// No additional platform storage needed
|
||||
}
|
||||
@@ -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<string> {
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-5
@@ -1,15 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
// Set the third parameter to '' to load all env regardless of the
|
||||
// `VITE_` prefix.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features
|
||||
const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true';
|
||||
const isDesktopMode =
|
||||
mode === 'desktop' ||
|
||||
process.env.STIRLING_DESKTOP === 'true' ||
|
||||
process.env.VITE_DESKTOP === 'true';
|
||||
env.STIRLING_DESKTOP === 'true' ||
|
||||
env.VITE_DESKTOP === 'true';
|
||||
|
||||
// Validate required environment variables for desktop builds
|
||||
if (isDesktopMode) {
|
||||
@@ -18,7 +24,7 @@ export default defineConfig(({ mode }) => {
|
||||
'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY',
|
||||
];
|
||||
|
||||
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
|
||||
const missingVars = requiredEnvVars.filter(varName => !env[varName]);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(
|
||||
@@ -108,6 +114,6 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',
|
||||
base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : './',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user