Compare commits

..
153 changed files with 2888 additions and 4432 deletions
@@ -1,21 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
/**
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
* module to provide authenticated users access to their personal signatures.
*/
public interface PersonalSignatureServiceInterface {
/**
* Get a personal signature from the user's folder. Only checks personal folder, not shared
* folder.
*
* @param username Username of the signature owner
* @param fileName Signature filename
* @return Personal signature image bytes
* @throws IOException If file not found or read error
*/
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
}
@@ -51,8 +51,7 @@ public class RequestUriUtilsTest {
@Test
void testIsFrontendRoute() {
assertTrue(
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.annotations.api.UiDataApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SharedSignatureService signatureService;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SharedSignatureService signatureService,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -25,7 +25,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
@Slf4j
public class GeneralWebController {
private final SharedSignatureService signatureService;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public GeneralWebController(
SharedSignatureService signatureService,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -6,14 +6,12 @@ import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ReactRoutingController {
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
@GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
@@ -0,0 +1,48 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.service.UserServiceInterface;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/api/v1/general")
public class SignatureController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
public SignatureController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.signatureService = signatureService;
this.userService = userService;
}
@GetMapping("/sign/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
throws IOException {
String username = "NON_SECURITY_USER";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Verify access permission
if (!signatureService.hasAccessToFile(username, fileName)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
return ResponseEntity.ok()
.contentType( // Adjust based on file type
MediaType.IMAGE_JPEG)
.body(imageBytes);
}
}
@@ -1,84 +0,0 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
* authenticated signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/general")
public class SignatureImageController {
private final SharedSignatureService sharedSignatureService;
private final PersonalSignatureServiceInterface personalSignatureService;
private final UserServiceInterface userService;
public SignatureImageController(
SharedSignatureService sharedSignatureService,
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.sharedSignatureService = sharedSignatureService;
this.personalSignatureService = personalSignatureService;
this.userService = userService;
}
/**
* Get a signature image (works for both authenticated and unauthenticated users). -
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
* community: tries shared only
*/
@GetMapping("/signatures/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
try {
byte[] imageBytes = null;
// If proprietary service available and user authenticated, try personal folder first
if (personalSignatureService != null && userService != null) {
try {
String username = userService.getCurrentUsername();
imageBytes =
personalSignatureService.getPersonalSignatureBytes(username, fileName);
} catch (Exception e) {
// Not found in personal folder or not authenticated, will try shared
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
}
}
// If not found in personal (or no personal service), try shared
if (imageBytes == null) {
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
}
// Determine content type from file extension
MediaType contentType = MediaType.IMAGE_PNG; // Default
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
contentType = MediaType.IMAGE_JPEG;
}
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
} catch (IOException e) {
log.debug("Signature not found: {}", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -1,18 +0,0 @@
package stirling.software.SPDF.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -1,22 +0,0 @@
package stirling.software.SPDF.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -1,308 +0,0 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SharedSignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = new ObjectMapper();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
/**
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
* authenticated and unauthenticated users.
*/
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
validateFileName(fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (!Files.exists(allUsersPath)) {
throw new FileNotFoundException("Shared signature file not found");
}
return Files.readAllBytes(allUsersPath);
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
/** Save a signature as image file */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file only
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
return response;
}
/** Get all saved signatures for a user */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
return signatures;
}
/** Delete a saved signature */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Try to find and delete image file in personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
// Try shared folder if not found in personal
if (!deleted) {
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found");
}
log.info("Deleted signature {} for user {}", signatureId, username);
}
}
@@ -0,0 +1,107 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
// First try user's personal folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (Files.exists(userPath)) {
return Files.readAllBytes(userPath);
}
// Then try ALL_USERS folder
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (Files.exists(allUsersPath)) {
return Files.readAllBytes(allUsersPath);
}
throw new FileNotFoundException("Signature file not found");
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg")
|| fileName.endsWith(".jpeg")
|| fileName.endsWith(".png")
|| fileName.endsWith(".gif");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
}
}
@@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
class SignatureServiceTest {
@TempDir Path tempDir;
private SharedSignatureService signatureService;
private SignatureService signatureService;
private Path personalSignatureFolder;
private Path sharedSignatureFolder;
private final String ALL_USERS_FOLDER = "ALL_USERS";
@@ -53,7 +53,7 @@ class SignatureServiceTest {
.thenReturn(tempDir.toString());
// Initialize the service with our temp directory
signatureService = new SharedSignatureService();
signatureService = new SignatureService();
}
}
@@ -165,7 +165,7 @@ class SignatureServiceTest {
}
@Test
void testGetSharedSignatureBytes_SharedFile() throws IOException {
void testGetSignatureBytes_PersonalFile() throws IOException {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -173,8 +173,28 @@ class SignatureServiceTest {
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test - core service only reads shared signatures
byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg");
// Test
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png");
// Verify
assertEquals(
"personal signature content",
new String(bytes),
"Should return the correct content for personal file");
}
}
@Test
void testGetSignatureBytes_SharedFile() throws IOException {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
mockedConfig
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg");
// Verify
assertEquals(
@@ -185,7 +205,7 @@ class SignatureServiceTest {
}
@Test
void testGetSharedSignatureBytes_FileNotFound() {
void testGetSignatureBytes_FileNotFound() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -196,13 +216,13 @@ class SignatureServiceTest {
// Test and verify
assertThrows(
FileNotFoundException.class,
() -> signatureService.getSharedSignatureBytes("nonexistent.png"),
() -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"),
"Should throw exception for non-existent files");
}
}
@Test
void testGetSharedSignatureBytes_InvalidFileName() {
void testGetSignatureBytes_InvalidFileName() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -213,28 +233,11 @@ class SignatureServiceTest {
// Test and verify
assertThrows(
IllegalArgumentException.class,
() -> signatureService.getSharedSignatureBytes("../invalid.png"),
() -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"),
"Should throw exception for file names with directory traversal");
}
}
@Test
void testGetSharedSignatureBytes_CannotAccessPersonalFiles() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
mockedConfig
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test and verify - core service should NOT be able to read personal files
assertThrows(
FileNotFoundException.class,
() -> signatureService.getSharedSignatureBytes("personal.png"),
"Core service should not have access to personal signatures");
}
}
@Test
void testGetAvailableSignatures_EmptyUsername() throws IOException {
// Mock static method for each test
@@ -31,10 +31,12 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.security.config.PremiumEndpoint;
@Slf4j
@ConvertApi
@RequiredArgsConstructor
@PremiumEndpoint
public class ConvertPdfJsonController {
private final PdfJsonConversionService pdfJsonConversionService;
@@ -1,102 +0,0 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.SignatureService;
/**
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
*/
@UserApi
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
@RequiredArgsConstructor
@PreAuthorize("isAuthenticated()")
public class SignatureController {
private final SignatureService signatureService;
private final UserService userService;
/**
* Save a new signature for the authenticated user. Enforces storage limits and authentication
* requirements.
*/
@PostMapping
public ResponseEntity<SavedSignatureResponse> saveSignature(
@RequestBody SavedSignatureRequest request) {
try {
String username = userService.getCurrentUsername();
// Validate request
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
log.warn("User {} attempted to save signature without dataUrl", username);
return ResponseEntity.badRequest().build();
}
SavedSignatureResponse response = signatureService.saveSignature(username, request);
log.info("User {} saved signature {}", username, request.getId());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid signature save request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (IOException e) {
log.error("Failed to save signature", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* List all signatures accessible to the authenticated user. Includes both personal and shared
* signatures.
*/
@GetMapping
public ResponseEntity<List<SavedSignatureResponse>> listSignatures() {
try {
String username = userService.getCurrentUsername();
List<SavedSignatureResponse> signatures = signatureService.getSavedSignatures(username);
return ResponseEntity.ok(signatures);
} catch (IOException e) {
log.error("Failed to list signatures for user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Delete a signature owned by the authenticated user. Users can only delete their own personal
* signatures, not shared ones.
*/
@DeleteMapping("/{signatureId}")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
try {
String username = userService.getCurrentUsername();
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -1,18 +0,0 @@
package stirling.software.proprietary.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -35,40 +35,15 @@ public class MailConfig {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
mailSender.setPort(mailProperties.getPort());
mailSender.setUsername(mailProperties.getUsername());
mailSender.setPassword(mailProperties.getPassword());
mailSender.setDefaultEncoding("UTF-8");
// Only set username and password if they are provided
String username = mailProperties.getUsername();
String password = mailProperties.getPassword();
boolean hasCredentials =
(username != null && !username.trim().isEmpty())
|| (password != null && !password.trim().isEmpty());
if (username != null && !username.trim().isEmpty()) {
mailSender.setUsername(username);
log.info("SMTP username configured");
} else {
log.info("SMTP username not configured - using anonymous connection");
}
if (password != null && !password.trim().isEmpty()) {
mailSender.setPassword(password);
log.info("SMTP password configured");
} else {
log.info("SMTP password not configured");
}
// Retrieves the JavaMail properties to configure additional SMTP parameters
Properties props = mailSender.getJavaMailProperties();
// Only enable SMTP authentication if credentials are provided
if (hasCredentials) {
props.put("mail.smtp.auth", "true");
log.info("SMTP authentication enabled");
} else {
props.put("mail.smtp.auth", "false");
log.info("SMTP authentication disabled - no credentials provided");
}
// Enables SMTP authentication
props.put("mail.smtp.auth", "true");
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
@@ -1,32 +1,22 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
@@ -252,159 +242,4 @@ public class AdminLicenseController {
.body(Map.of("error", "Failed to retrieve license information"));
}
}
/**
* Upload a license certificate file for offline activation. Accepts .lic or .cert files,
* validates the certificate format, saves to configs directory, and activates the license.
*
* @param file The license certificate file to upload
* @return Response with success status, license type, and file information
*/
@PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Upload license certificate file",
description =
"Upload a license certificate file (.lic, .cert) for offline activation."
+ " Validates the file format and activates the license.")
public ResponseEntity<Map<String, Object>> uploadLicenseFile(
@RequestParam("file") MultipartFile file) {
// Validate file exists
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File is empty"));
}
String filename = file.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid filename"));
}
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
}
// Validate file extension
if (!isValidLicenseFile(filename)) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid file type. Expected .lic or .cert"));
}
// Check file size (max 1MB for license files)
if (file.getSize() > 1_048_576) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"));
}
try {
// Validate certificate format by reading content
byte[] fileBytes = file.getBytes();
String content = new String(fileBytes, StandardCharsets.UTF_8);
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid license certificate format"));
}
// Get config directory and target path
Path configPath = Paths.get(InstallationPathConfig.getConfigPath());
Path targetPath = configPath.resolve(filename).normalize();
// Prevent directory traversal: ensure targetPath is inside configPath
if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid file path"));
}
// Backup existing file if present
if (Files.exists(targetPath)) {
Path backupDir = configPath.resolve("backup");
Files.createDirectories(backupDir);
String backupFilename = filename + ".bak." + System.currentTimeMillis();
Path backupPath = backupDir.resolve(backupFilename);
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
log.info("Backed up existing license file to: {}", backupPath);
}
// Write new license file
Files.write(targetPath, fileBytes);
log.info("License file saved to: {}", targetPath);
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
// Update settings with file reference (relative path)
String fileReference = "file:configs/" + filename;
licenseKeyChecker.updateLicenseKey(fileReference);
// Get license status after activation
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("filename", filename);
response.put("filePath", "configs/" + filename);
response.put("enabled", applicationProperties.getPremium().isEnabled());
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
response.put("message", "License file uploaded and activated");
log.info(
"License file uploaded and activated: filename={}, type={}",
filename,
license.name());
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to save license file", e);
return ResponseEntity.internalServerError()
.body(
Map.of(
"success",
false,
"error",
"Failed to save license file: " + e.getMessage()));
} catch (Exception e) {
log.error("Failed to activate license from file", e);
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
}
}
/**
* Validates if the filename has a valid license file extension (.lic or .cert)
*
* @param filename The filename to validate
* @return true if the filename ends with .lic or .cert (case-insensitive)
*/
private boolean isValidLicenseFile(String filename) {
if (filename == null) {
return false;
}
String lower = filename.toLowerCase();
return lower.endsWith(".lic") || lower.endsWith(".cert");
}
}
@@ -407,8 +407,7 @@ public class UserController {
public ResponseEntity<?> inviteUsers(
@RequestParam(name = "emails", required = true) String emails,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
HttpServletRequest request)
@RequestParam(name = "teamId", required = false) Long teamId)
throws SQLException, UnsupportedProviderException {
// Check if email invites are enabled
@@ -478,9 +477,6 @@ public class UserController {
}
}
// Build login URL
String loginUrl = buildLoginUrl(request);
int successCount = 0;
int failureCount = 0;
StringBuilder errors = new StringBuilder();
@@ -492,7 +488,7 @@ public class UserController {
continue;
}
InviteResult result = processEmailInvite(email, effectiveTeamId, role, loginUrl);
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
if (result.isSuccess()) {
successCount++;
} else {
@@ -691,45 +687,15 @@ public class UserController {
return ResponseEntity.ok(apiKey);
}
/**
* Helper method to build the login URL from the application configuration or request.
*
* @param request The HTTP request
* @return The login URL
*/
private String buildLoginUrl(HttpServletRequest request) {
String baseUrl;
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
// Use configured frontend URL (remove trailing slash if present)
baseUrl =
configuredFrontendUrl.endsWith("/")
? configuredFrontendUrl.substring(0, configuredFrontendUrl.length() - 1)
: configuredFrontendUrl;
} else {
// Fall back to backend URL from request
baseUrl =
request.getScheme()
+ "://"
+ request.getServerName()
+ (request.getServerPort() != 80 && request.getServerPort() != 443
? ":" + request.getServerPort()
: "");
}
return baseUrl + "/login";
}
/**
* Helper method to process a single email invitation.
*
* @param email The email address to invite
* @param teamId The team ID to assign the user to
* @param role The role to assign to the user
* @param loginUrl The URL to the login page
* @return InviteResult containing success status and optional error message
*/
private InviteResult processEmailInvite(
String email, Long teamId, String role, String loginUrl) {
private InviteResult processEmailInvite(String email, Long teamId, String role) {
try {
// Validate email format (basic check)
if (!email.contains("@") || !email.contains(".")) {
@@ -749,7 +715,7 @@ public class UserController {
// Send invite email
try {
emailService.get().sendInviteEmail(email, email, temporaryPassword, loginUrl);
emailService.get().sendInviteEmail(email, email, temporaryPassword);
log.info("Sent invite email to: {}", email);
return InviteResult.success();
} catch (Exception emailEx) {
@@ -56,19 +56,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
List<User> findAllSsoUsers();
/**
* Finds SSO users who have never created a session (pending activation) and are not yet
* grandfathered.
*/
@Query(
"SELECT u FROM User u "
+ "LEFT JOIN SessionEntity s ON u.username = s.principalName "
+ "WHERE (u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) "
+ "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) "
+ "AND s.sessionId IS NULL")
List<User> findPendingSsoUsersWithoutSession();
/**
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
*/
@@ -105,18 +105,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
log.debug("JWT validation failed: {}", e.getMessage());
log.warn("JWT validation failed: {}", e.getMessage());
handleAuthenticationFailure(request, response, e);
return;
}
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -115,12 +115,10 @@ public class EmailService {
* @param to The recipient email address
* @param username The username for the new account
* @param temporaryPassword The temporary password
* @param loginUrl The URL to the login page
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendInviteEmail(
String to, String username, String temporaryPassword, String loginUrl)
public void sendInviteEmail(String to, String username, String temporaryPassword)
throws MessagingException {
String subject = "Welcome to Stirling PDF";
@@ -146,14 +144,6 @@ public class EmailService {
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 30px 0;">
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Log In to Stirling PDF</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
%s
</div>
<p>Please keep these credentials secure and do not share them with anyone.</p>
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
</div>
@@ -165,7 +155,7 @@ public class EmailService {
</div>
</body></html>
"""
.formatted(username, temporaryPassword, loginUrl, loginUrl);
.formatted(username, temporaryPassword);
sendPlainEmail(to, subject, body, true);
}
@@ -50,6 +50,7 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -255,9 +256,11 @@ public class JwtService implements JwtServiceInterface {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
log.debug("JWT token extracted from Authorization header");
return token;
}
log.debug("No JWT token found in Authorization header");
return null;
}
@@ -280,9 +283,10 @@ public class JwtService implements JwtServiceInterface {
.parse(token)
.getHeader()
.get("kid");
log.debug("Extracted key ID from token: {}", keyId);
return keyId;
} catch (Exception e) {
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
return null;
}
}
@@ -55,6 +55,7 @@ public class KeyPairCleanupService {
return;
}
log.info("Removing keys older than retention period");
removeKeys(eligibleKeys);
keyPersistenceService.refreshActiveKeyPair();
}
@@ -778,30 +778,4 @@ public class UserService implements UserServiceInterface {
return updated;
}
/**
* Grandfathers SSO users who have never created a session (invited/pending accounts). These
* users would otherwise be blocked when SSO requires a paid license despite existing before the
* policy change.
*
* @return Number of pending users updated
*/
@Transactional
public int grandfatherPendingSsoUsersWithoutSession() {
List<User> pendingUsers = userRepository.findPendingSsoUsersWithoutSession();
int updated = 0;
for (User user : pendingUsers) {
if (!user.isOauthGrandfathered()) {
user.setOauthGrandfathered(true);
updated++;
}
}
if (updated > 0) {
userRepository.saveAll(pendingUsers);
}
return updated;
}
}
@@ -1,299 +0,0 @@
package stirling.software.proprietary.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
/**
* Service for managing user signatures with authentication and storage limits. This proprietary
* version enforces per-user quotas and requires authentication. Provides access to personal
* signatures only (shared signatures handled by core service).
*/
@Service
@Slf4j
public class SignatureService implements PersonalSignatureServiceInterface {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
// Storage limits per user
private static final int MAX_SIGNATURES_PER_USER = 20;
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature
private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
/**
* Get a personal signature from the user's folder only. Does NOT check shared folder (that's
* handled by core service).
*/
@Override
public byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (!Files.exists(userPath)) {
throw new FileNotFoundException("Personal signature not found");
}
return Files.readAllBytes(userPath);
}
/** Save a signature with storage limits enforced. */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
// Only enforce limits for personal signatures (not shared)
if ("personal".equals(scope)) {
enforceStorageLimits(username, request.getDataUrl());
}
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Validate dataUrl size before decoding
if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) {
throw new IllegalArgumentException(
"Signature data too large (max "
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
+ "KB)");
}
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Validate decoded size
if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) {
throw new IllegalArgumentException(
"Signature image too large (max "
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
+ "KB)");
}
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file (unified endpoint for all signatures)
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
return response;
}
/** Get all saved signatures for a user (personal + shared). */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
signatures.addAll(loadSignaturesFromFolder(personalFolder, "personal", true));
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
signatures.addAll(loadSignaturesFromFolder(sharedFolder, "shared", false));
}
return signatures;
}
/** Delete a signature from user's personal folder. Cannot delete shared signatures. */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Only allow deletion from personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
log.info("Deleted signature file: {}", file);
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found or cannot be deleted");
}
}
// Private helper methods
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (!Files.exists(userFolder)) {
return; // First signature, no limits to check
}
// Count existing signatures
long signatureCount;
try (Stream<Path> stream = Files.list(userFolder)) {
signatureCount = stream.filter(this::isImageFile).count();
}
if (signatureCount >= MAX_SIGNATURES_PER_USER) {
throw new IllegalArgumentException(
"Maximum signatures limit reached (" + MAX_SIGNATURES_PER_USER + ")");
}
// Calculate total storage used
long totalSize = 0;
try (Stream<Path> stream = Files.list(userFolder)) {
totalSize =
stream.filter(this::isImageFile)
.mapToLong(
path -> {
try {
return Files.size(path);
} catch (IOException e) {
return 0;
}
})
.sum();
}
// Estimate new signature size (base64 decodes to ~75% of original)
long estimatedNewSize = (long) (dataUrlToAdd.length() * 0.75);
if (totalSize + estimatedNewSize > MAX_TOTAL_USER_STORAGE_BYTES) {
throw new IllegalArgumentException(
"Storage quota exceeded (max "
+ (MAX_TOTAL_USER_STORAGE_BYTES / 1_000_000)
+ "MB)");
}
}
private List<SavedSignatureResponse> loadSignaturesFromFolder(
Path folder, String scope, boolean isPersonal) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
try (Stream<Path> stream = Files.list(folder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id = fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id);
sig.setType("image");
sig.setScope(scope);
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
// Set unified URL path (works for both personal and shared)
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
return signatures;
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
}
@@ -192,18 +192,10 @@ public class UserLicenseSettingsService {
+ "They will retain OAuth access even without a paid license. "
+ "New users will require a paid license for OAuth.",
updated);
}
// Grandfather pending users (invited but never logged in)
// The query filters to non-grandfathered users only, so this is idempotent
if (grandfatheredCount > 0 || oauthUsersCount > 0) {
int pendingUpdated = userService.grandfatherPendingSsoUsersWithoutSession();
if (pendingUpdated > 0) {
log.warn(
"OAuth GRANDFATHERING: Marked {} pending SSO users (no prior sessions) as"
+ " grandfathered.",
pendingUpdated);
}
} else if (grandfatheredCount > 0) {
log.debug(
"OAuth grandfathering already completed: {} users grandfathered",
grandfatheredCount);
}
}
}
@@ -2,9 +2,6 @@ package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
@@ -201,70 +198,4 @@ class UserLicenseSettingsServiceTest {
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
}
@Test
void grandfatherExistingOAuthUsers_runsOnlyWhenNoneGrandfathered() {
// With grandfatheredCount == 0, should run grandfathering for all users
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
when(userService.grandfatherAllOAuthUsers()).thenReturn(10);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
service.grandfatherExistingOAuthUsers();
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_skipsMainButRunsPendingWhenSomeAlreadyGrandfathered() {
// V2→V2.1 upgrade: some users already grandfathered, but pending users need to be checked
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(4L);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(2);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_stillChecksPendingWhenAllUsersGrandfathered() {
// All active users grandfathered, but still check for pending users
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(10L);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_skipsWhenNoOAuthUsers() {
when(userService.countOAuthUsers()).thenReturn(0L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, never()).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_grandfathersPendingUsersOnFirstRun() {
// Pending users (invited but never logged in) should be grandfathered
// during the initial grandfathering run (when grandfatheredCount == 0)
when(userService.countOAuthUsers()).thenReturn(5L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
when(userService.grandfatherAllOAuthUsers()).thenReturn(5);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(3);
service.grandfatherExistingOAuthUsers();
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.2'
version = '2.0.1'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
-18
View File
@@ -20,7 +20,6 @@
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-print": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
@@ -742,23 +741,6 @@
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.4.1.tgz",
"integrity": "sha512-YEjU6rQVW8wb125JXl1wma95+JISwADZpfqZZOtvPBRABp6ce4byblDTNjWVmTYWSgKUZrlXCL3Ff3Ig+bjbjw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
"svelte": ">=5 <6",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
-1
View File
@@ -16,7 +16,6 @@
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-print": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
+1 -31
View File
@@ -2267,16 +2267,8 @@ defaultCanvasLabel = "Drawing signature"
defaultImageLabel = "Uploaded signature"
defaultTextLabel = "Typed signature"
saveButton = "Save signature"
savePersonal = "Save Personal"
saveShared = "Save Shared"
saveUnavailable = "Create a signature first to save it."
noChanges = "Current signature is already saved."
tempStorageTitle = "Temporary browser storage"
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
personalHeading = "Personal Signatures"
sharedHeading = "Shared Signatures"
personalDescription = "Only you can see these signatures."
sharedDescription = "All users can see and use these signatures."
[sign.saved.type]
canvas = "Drawing"
@@ -3899,7 +3891,6 @@ toggleSidebar = "Toggle Sidebar"
exportSelected = "Export Selected Pages"
toggleAnnotations = "Toggle Annotations Visibility"
annotationMode = "Toggle Annotation Mode"
print = "Print PDF"
draw = "Draw"
save = "Save"
saveChanges = "Save Changes"
@@ -4517,7 +4508,6 @@ description = "URL or filename to impressum (required in some jurisdictions)"
title = "Premium & Enterprise"
description = "Configure your premium or enterprise license key."
license = "License Configuration"
noInput = "Please provide a license key or file"
[admin.settings.premium.licenseKey]
toggle = "Got a license key or certificate file?"
@@ -4535,26 +4525,6 @@ line1 = "Overwriting your current license key cannot be undone."
line2 = "Your previous license will be permanently lost unless you have backed it up elsewhere."
line3 = "Important: Keep license keys private and secure. Never share them publicly."
[admin.settings.premium.inputMethod]
text = "License Key"
file = "Certificate File"
[admin.settings.premium.file]
label = "License Certificate File"
description = "Upload your .lic or .cert license file from offline purchases"
choose = "Choose License File"
selected = "Selected: {{filename}} ({{size}})"
successMessage = "License file uploaded and activated successfully. No restart required."
[admin.settings.premium.currentLicense]
title = "Active License"
file = "Source: License file ({{path}})"
key = "Source: License key"
type = "Type: {{type}}"
noInput = "Please provide a license key or upload a certificate file"
success = "Success"
[admin.settings.premium.enabled]
label = "Enable Premium Features"
description = "Enable license key checks for pro/enterprise features"
@@ -5291,7 +5261,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "At least one email address is required"
submit = "Send Invites"
success = "user(s) invited successfully"
partialFailure = "Some invites failed"
partialSuccess = "Some invites failed"
allFailed = "Failed to invite users"
error = "Failed to send invites"
+2 -2
View File
@@ -11,8 +11,8 @@
{
"identifier": "http:allow-fetch",
"allow": [
{ "url": "http://*" },
{ "url": "http://*:*" },
{ "url": "http://localhost:*" },
{ "url": "http://127.0.0.1:*" },
{ "url": "https://*" }
]
},
-1
View File
@@ -3,7 +3,6 @@ Version=1.0
Type=Application
Name=Stirling-PDF
Comment=Locally hosted web application that allows you to perform various operations on PDF files
Exec=/usr/bin/stirling-pdf
Icon={{icon}}
Terminal=false
MimeType=application/pdf;
+2 -2
View File
@@ -3,7 +3,7 @@ import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import Onboarding from "@app/components/onboarding/Onboarding";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
// Import global styles
import "@app/styles/tailwind.css";
@@ -19,7 +19,7 @@ export default function App() {
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
@@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
@@ -76,6 +77,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
@@ -111,6 +113,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
@@ -0,0 +1,33 @@
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
export interface FlowConfig {
type: FlowType;
ids: SlideId[];
}
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
if (!enableLogin) {
return selfReportedAdmin
? {
type: 'no-login-admin',
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
}
: {
type: 'no-login',
ids: FLOW_SEQUENCES.noLoginBase,
};
}
return isAdmin
? {
type: 'login-admin',
ids: FLOW_SEQUENCES.loginAdmin,
}
: {
type: 'login-user',
ids: FLOW_SEQUENCES.loginUser,
};
}
@@ -1,44 +1,33 @@
/**
* OnboardingModalSlide Component
*
* Renders a single modal slide in the onboarding flow.
* Handles the hero image, content, stepper, and button actions.
*/
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
import type { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface OnboardingModalSlideProps {
slideDefinition: SlideDefinition;
slideContent: SlideConfig;
runtimeState: OnboardingRuntimeState;
modalSlideCount: number;
currentModalSlideIndex: number;
onSkip: () => void;
onAction: (action: ButtonAction) => void;
}
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
const flow = useInitialOnboardingState(props);
export default function OnboardingModalSlide({
slideDefinition,
slideContent,
runtimeState,
modalSlideCount,
currentModalSlideIndex,
onSkip,
onAction,
}: OnboardingModalSlideProps) {
if (!flow) {
return null;
}
const {
state,
totalSteps,
currentSlide,
slideDefinition,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
@@ -59,9 +48,6 @@ export default function OnboardingModalSlide({
{slideDefinition.hero.type === 'shield' && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'lock' && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
@@ -72,8 +58,8 @@ export default function OnboardingModalSlide({
return (
<Modal
opened={true}
onClose={onSkip}
opened={props.opened}
onClose={closeAndMarkSeen}
closeOnClickOutside={false}
centered
size="lg"
@@ -81,48 +67,48 @@ export default function OnboardingModalSlide({
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
body: { padding: 0 },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
}}
>
<Stack gap={0} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={slideContent.background.gradientStops}
circles={slideContent.background.circles}
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
isActive
slideKey={slideContent.key}
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
<div className={styles.modalBody}>
<Stack gap={16}>
<div
key={`title-${slideContent.key}`}
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{slideContent.title}
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{slideContent.body}
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
<div className={styles.buttonContainer}>
<SlideButtons
slideDefinition={slideDefinition}
licenseNotice={runtimeState.licenseNotice}
flowState={{ selectedRole: runtimeState.selectedRole }}
onAction={onAction}
/>
{renderButtons({
slideDefinition,
licenseNotice,
flowState,
onAction: handleButtonAction,
})}
</div>
</Stack>
</div>
@@ -6,7 +6,7 @@ import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onb
import type { LicenseNotice } from '@app/types/types';
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
interface SlideButtonsProps {
interface RenderButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
@@ -16,7 +16,7 @@ interface SlideButtonsProps {
onAction: (action: ButtonAction) => void;
}
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
@@ -105,4 +105,5 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
}
@@ -0,0 +1,21 @@
import type { LicenseNotice } from '@app/types/types';
export interface InitialOnboardingModalProps {
opened: boolean;
onClose: () => void;
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
}
export interface OnboardingState {
step: number;
selectedRole: 'admin' | 'user' | null;
selfReportedAdmin: boolean;
}
export const DEFAULT_STATE: OnboardingState = {
step: 0,
selectedRole: null,
selfReportedAdmin: false,
};
@@ -0,0 +1,381 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useOs } from '@app/hooks/useOs';
import { useNavigate } from 'react-router-dom';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface UseInitialOnboardingStateResult {
state: OnboardingState;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
licenseNotice: LicenseNotice;
flowState: FlowState;
closeAndMarkSeen: () => void;
handleButtonAction: (action: ButtonAction) => void;
}
export function useInitialOnboardingState({
opened,
onClose,
onRequestServerLicense,
onLicenseNoticeUpdate,
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
const { preferences, updatePreference } = usePreferences();
const { startTour } = useOnboarding();
const {
loginEnabled: loginEnabledFromServer,
configIsAdmin,
totalUsers: serverTotalUsers,
userCountResolved: serverUserCountResolved,
freeTierLimit,
hasPaidLicense,
scenarioKey,
setSelfReportedAdmin,
isNewServer,
} = useServerExperience();
const osType = useOs();
const navigate = useNavigate();
const selectedDownloadUrlRef = useRef<string>('');
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
const resetState = useCallback(() => {
setState(DEFAULT_STATE);
}, []);
useEffect(() => {
if (!opened) {
resetState();
}
}, [opened, resetState]);
const handleRoleSelect = useCallback(
(role: 'admin' | 'user' | null) => {
const isAdminSelection = role === 'admin';
setState((prev) => ({
...prev,
selectedRole: role,
selfReportedAdmin: isAdminSelection,
}));
if (typeof window !== 'undefined') {
if (isAdminSelection) {
window.localStorage.setItem('stirling-self-reported-admin', 'true');
} else {
window.localStorage.removeItem('stirling-self-reported-admin');
}
}
setSelfReportedAdmin(isAdminSelection);
},
[setSelfReportedAdmin],
);
const closeAndMarkSeen = useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
onClose();
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
const isAdmin = configIsAdmin;
const enableLogin = loginEnabledFromServer;
const effectiveEnableLogin = enableLogin;
const effectiveIsAdmin = isAdmin;
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
useEffect(() => {
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
handleRoleSelect('admin');
}
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
const shouldUseServerCount =
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
const licenseUserCountFromServer =
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo(() => {
const options = [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
];
return options.filter(opt => opt.url);
}, []);
const resolvedFlow = useMemo(
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
);
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
const flowSlideIds = useMemo(
() =>
shouldSkipSecurityCheck
? resolvedFlow.ids.filter((id) => id !== 'security-check')
: resolvedFlow.ids,
[resolvedFlow.ids, shouldSkipSecurityCheck],
);
const flowType = resolvedFlow.type;
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
useEffect(() => {
if (state.step >= flowSlideIds.length) {
setState((prev) => ({
...prev,
step: Math.max(flowSlideIds.length - 1, 0),
}));
}
}, [flowSlideIds.length, state.step]);
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
if (!slideDefinition) {
return null;
}
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
const scenarioIndicatesAdmin = scenarioProvidesInfo
? scenarioKey!.includes('admin')
: state.selfReportedAdmin || effectiveIsAdmin;
const scenarioIndicatesOverLimit = scenarioProvidesInfo
? scenarioKey!.includes('over-limit')
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
const scenarioRequiresLicense =
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
const licenseNotice = useMemo<LicenseNotice>(
() => ({
totalUsers: effectiveLicenseUserCount,
freeTierLimit,
isOverLimit: scenarioIndicatesOverLimit,
requiresLicense: shouldShowServerLicenseInfo,
}),
[
effectiveLicenseUserCount,
freeTierLimit,
scenarioIndicatesOverLimit,
shouldShowServerLicenseInfo,
],
);
const requestServerLicenseIfNeeded = useCallback(
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
if (!shouldShowServerLicenseInfo) {
return;
}
onRequestServerLicense?.(options);
},
[onRequestServerLicense, shouldShowServerLicenseInfo],
);
useEffect(() => {
onLicenseNoticeUpdate?.(licenseNotice);
}, [licenseNotice, onLicenseNoticeUpdate]);
// Initialize ref with default URL
useEffect(() => {
if (!selectedDownloadUrlRef.current && os.url) {
selectedDownloadUrlRef.current = os.url;
}
}, [os.url]);
const handleDownloadUrlChange = useCallback((url: string) => {
selectedDownloadUrlRef.current = url;
}, []);
const currentSlide = slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
osOptions,
onDownloadUrlChange: handleDownloadUrlChange,
selectedRole: state.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice,
loginEnabled: effectiveEnableLogin,
});
const goNext = useCallback(() => {
setState((prev) => ({
...prev,
step: Math.min(prev.step + 1, maxIndex),
}));
}, [maxIndex]);
const goPrev = useCallback(() => {
setState((prev) => ({
...prev,
step: Math.max(prev.step - 1, 0),
}));
}, []);
const launchTour = useCallback(
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
if (options?.closeOnboardingSlides) {
closeAndMarkSeen();
}
startTour(mode, {
source: 'initial-onboarding-modal',
metadata: {
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
selfReportedAdmin: state.selfReportedAdmin,
},
});
},
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
);
const handleButtonAction = useCallback(
(action: ButtonAction) => {
const currentSlideIdLocal = currentSlideId;
const shouldAutoLaunchLoginUserTour =
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
switch (action) {
case 'next':
if (shouldAutoLaunchLoginUserTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
case 'prev':
goPrev();
return;
case 'close':
closeAndMarkSeen();
return;
case 'download-selected': {
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
if (shouldAutoLaunchLoginUserTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
}
case 'complete-close':
updatePreference('hasCompletedOnboarding', true);
closeAndMarkSeen();
return;
case 'security-next':
if (!state.selectedRole) {
return;
}
if (state.selectedRole === 'admin') {
goNext();
} else {
launchTour('tools', { closeOnboardingSlides: true });
}
return;
case 'launch-admin':
requestServerLicenseIfNeeded({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
launchTour('admin', { closeOnboardingSlides: true });
return;
case 'launch-tools':
launchTour('tools', { closeOnboardingSlides: true });
return;
case 'launch-auto': {
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
if (launchMode === 'admin') {
requestServerLicenseIfNeeded({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
}
launchTour(launchMode, { closeOnboardingSlides: true });
return;
}
case 'skip-to-license':
updatePreference('hasCompletedOnboarding', true);
requestServerLicenseIfNeeded({
deferUntilTourComplete: false,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
closeAndMarkSeen();
return;
case 'see-plans':
closeAndMarkSeen();
navigate('/settings/adminPlan');
return;
default:
return;
}
},
[
closeAndMarkSeen,
currentSlide,
effectiveIsAdmin,
flowType,
goNext,
goPrev,
launchTour,
navigate,
requestServerLicenseIfNeeded,
onRequestServerLicense,
os.url,
state.selectedRole,
state.selfReportedAdmin,
updatePreference,
],
);
const flowState: FlowState = { selectedRole: state.selectedRole };
return {
state,
totalSteps,
slideDefinition,
currentSlide,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
};
}
@@ -1,319 +0,0 @@
import { useEffect, useMemo, useCallback, useState } from 'react';
import { type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
import {
useServerLicenseRequest,
useTourRequest,
} from '@app/components/onboarding/useOnboardingEffects';
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useServerExperience } from '@app/hooks/useServerExperience';
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
import '@app/components/onboarding/OnboardingTour.css';
export default function Onboarding() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { state, actions } = useOnboardingOrchestrator();
const serverExperience = useServerExperience();
const onAuthRoute = isAuthRoute(location.pathname);
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]);
const handlePasswordChanged = useCallback(() => {
actions.updateRuntimeState({ requiresPasswordChange: false });
window.location.href = '/login';
}, [actions]);
const handleButtonAction = useCallback((action: ButtonAction) => {
switch (action) {
case 'next':
case 'complete-close':
actions.complete();
break;
case 'prev':
actions.prev();
break;
case 'close':
actions.skip();
break;
case 'download-selected':
handleDownloadSelected();
actions.complete();
break;
case 'security-next':
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
}
actions.complete();
break;
case 'launch-admin':
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
actions.complete();
break;
case 'launch-tools':
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
actions.complete();
break;
case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
actions.updateRuntimeState({ tourRequested: true, tourType });
actions.complete();
break;
}
case 'skip-to-license':
markStepSeen('tour');
actions.updateRuntimeState({ tourRequested: false });
actions.complete();
break;
case 'see-plans':
actions.complete();
navigate('/settings/adminPlan');
break;
}
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const tourOrch = useTourOrchestration();
const adminTourOrch = useAdminTourOrchestration();
const userStepsConfig = useMemo(
() => createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
);
const adminStepsConfig = useMemo(
() => createAdminStepsConfig({
t,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch]
);
const tourSteps = useMemo<StepType[]>(() => {
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
useEffect(() => {
if (currentStep?.id === 'tour' && !isTourOpen) {
markStepSeen('tour');
setIsTourOpen(true);
}
}, [currentStep, isTourOpen, activeFlow]);
useEffect(() => {
if (externalTourRequested) {
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
markStepSeen('tour');
setIsTourOpen(true);
clearTourRequest();
}
}, [externalTourRequested, requestedTourType, actions, clearTourRequest]);
useEffect(() => {
if (!isTourOpen) removeAllGlows();
return () => removeAllGlows();
}, [isTourOpen]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === 'admin') {
adminTourOrch.restoreAdminState();
} else {
tourOrch.restoreWorkbenchState();
}
markStepSeen('tour');
if (currentStep?.id === 'tour') actions.complete();
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
}, [finishTour]);
const handleCloseTour = useCallback((args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
}, [finishTour]);
const currentSlideDefinition = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
}, [currentStep]);
const currentSlideContent = useMemo(() => {
if (!currentSlideDefinition) return null;
return currentSlideDefinition.createSlide({
osLabel: osInfo.label,
osUrl: osInfo.url,
osOptions,
onDownloadUrlChange: setSelectedDownloadUrl,
selectedRole: runtimeState.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice: runtimeState.licenseNotice,
loginEnabled: serverExperience.loginEnabled,
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
});
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
}, [activeFlow]);
const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
if (onAuthRoute) {
return null;
}
if (showLicenseSlide) {
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
osOptions: [],
onDownloadUrlChange: () => {},
selectedRole: null,
onRoleSelect: () => {},
licenseNotice: effectiveLicenseNotice,
loginEnabled: serverExperience.loginEnabled,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
onAction={(action) => {
if (action === 'see-plans') {
closeLicenseSlide();
navigate('/settings/adminPlan');
} else {
closeLicenseSlide();
}
}}
/>
);
}
if (isLoading || !isActive || !currentStep) {
return (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
}
switch (currentStep.type) {
case 'tool-prompt':
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'tour':
return (
<OnboardingTour
isOpen={true}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
case 'analytics-modal':
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
case 'modal-slide':
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
<OnboardingModalSlide
slideDefinition={currentSlideDefinition}
slideContent={currentSlideContent}
runtimeState={runtimeState}
modalSlideCount={modalSlideCount}
currentModalSlideIndex={currentModalSlideIndex}
onSkip={actions.skip}
onAction={handleButtonAction}
/>
);
default:
return null;
}
}
@@ -1,151 +1,231 @@
/**
* OnboardingTour Component
*
* Reusable tour wrapper that encapsulates all Reactour configuration.
* Used by the main Onboarding component for both the 'tour' step and
* when the tour is open but onboarding is inactive.
*/
import React from 'react';
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import React, { useEffect, useMemo } from "react";
import { TourProvider, type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { CloseButton, ActionIcon } from '@mantine/core';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CheckIcon from '@mui/icons-material/Check';
import type { TFunction } from 'i18next';
import i18n from '@app/i18n';
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
import '@app/components/onboarding/OnboardingTour.css';
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import TourContent from '@app/components/onboarding/TourContent';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
/**
* TourContent - Controls the tour visibility
* Syncs the forceOpen prop with the reactour tour state.
*/
function TourContent({ forceOpen = false }: { forceOpen?: boolean }) {
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(forceOpen);
export default function OnboardingTour() {
const { t } = useTranslation();
const flow = useOnboardingFlow();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const {
saveWorkbenchState,
restoreWorkbenchState,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
} = useTourOrchestration();
const {
saveAdminState,
restoreAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
} = useAdminTourOrchestration();
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
previousIsOpenRef.current = forceOpen;
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
if (wasClosedNowOpen) {
setCurrentStep(0);
useEffect(() => {
if (!flow.isTourOpen) {
removeAllGlows();
}
setIsOpen(forceOpen);
}, [forceOpen, setIsOpen, setCurrentStep]);
return () => removeAllGlows();
}, [flow.isTourOpen]);
return null;
}
const userStepsConfig = useMemo(
() =>
createUserStepsConfig({
t,
actions: {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
openFilesModal,
},
}),
[
t,
backToAllTools,
closeFilesModal,
executeTool,
loadSampleFile,
modifyCropSettings,
openFilesModal,
pinFile,
saveWorkbenchState,
selectCropTool,
selectFirstFile,
switchToActiveFiles,
switchToPageEditor,
switchToViewer,
],
);
interface AdvanceArgs {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}
const adminStepsConfig = useMemo(
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
},
}),
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
);
interface CloseArgs {
setIsOpen: (value: boolean) => void;
}
const steps = useMemo<StepType[]>(() => {
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools';
isRTL: boolean;
t: TFunction;
isOpen: boolean;
onAdvance: (args: AdvanceArgs) => void;
onClose: (args: CloseArgs) => void;
}
const advanceTour = ({
setCurrentStep,
currentStep,
steps,
setIsOpen,
}: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
flow.handleTourCompletion();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
};
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
if (!isOpen) return null;
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
flow.handleTourCompletion();
};
return (
<TourProvider
key={`${tourType}-${i18n.language}`}
steps={tourSteps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
onClickClose={onClose}
onClickMask={onAdvance}
onClickHighlighted={(e, clickProps) => {
e.stopPropagation();
onAdvance(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
onAdvance(clickProps);
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
onClose(clickProps);
}
}}
rtl={isRTL}
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}),
maskArea: (base) => ({
...base,
rx: 8,
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
}),
controls: (base) => ({
...base,
justifyContent: 'center',
}),
}}
highlightedMaskClassName="tour-highlight-glow"
showNavigation={true}
showBadge={false}
showCloseButton={true}
disableInteraction={true}
disableDotsNavigation={false}
prevButton={() => null}
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
const isLast = tourCurrentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return (
<ActionIcon
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
);
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
<TourContent forceOpen={true} />
</TourProvider>
<>
<InitialOnboardingModal {...flow.initialModalProps} />
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
<TourProvider
key={`${flow.tourType}-${i18n.language}`}
steps={steps}
maskClassName={flow.maskClassName}
onClickClose={handleCloseTour}
onClickMask={advanceTour}
onClickHighlighted={(e, clickProps) => {
e.stopPropagation();
advanceTour(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
advanceTour(clickProps);
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
handleCloseTour(clickProps);
}
}}
rtl={isRTL}
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}),
maskArea: (base) => ({
...base,
rx: 8,
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
}),
controls: (base) => ({
...base,
justifyContent: 'center',
}),
}}
highlightedMaskClassName="tour-highlight-glow"
showNavigation={true}
showBadge={false}
showCloseButton={true}
disableInteraction={true}
disableDotsNavigation={false}
prevButton={() => null}
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
const isLast = currentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return (
<ActionIcon
onClick={() => advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
);
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
<TourContent />
</TourProvider>
<ServerLicenseModal {...flow.serverLicenseModalProps} />
</>
);
}
export type { AdvanceArgs, CloseArgs };
@@ -0,0 +1,120 @@
import React from 'react';
import { Modal, Button, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import { LicenseNotice } from '@app/types/types';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { BASE_PATH } from '@app/constants/app';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface ServerLicenseModalProps {
opened: boolean;
onClose: () => void;
onSeePlans?: () => void;
licenseNotice: LicenseNotice;
}
export default function ServerLicenseModal({
opened,
onClose,
onSeePlans,
licenseNotice,
}: ServerLicenseModalProps) {
const { t } = useTranslation();
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
const primaryLabel = licenseNotice.isOverLimit
? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
: t('onboarding.serverLicense.seePlans', 'See Plans →');
const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
const handleSeePlans = () => {
onSeePlans?.();
onClose();
};
const secondaryStyles = {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
};
const primaryStyles = {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
}}
>
<Stack gap={0}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={slide.background.gradientStops}
circles={slide.background.circles}
isActive
slideKey={slide.key}
/>
<div className={styles.heroLogo}>
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
</div>
</div>
</div>
</div>
<div style={{ padding: 24 }}>
<Stack gap={16}>
<div
className={styles.title}
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontWeight: 600,
fontSize: 22,
color: 'var(--onboarding-title)',
}}
>
{slide.title}
</div>
<div
className={styles.bodyCopy}
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontSize: 16,
color: 'var(--onboarding-body)',
lineHeight: 1.5,
}}
>
{slide.body}
</div>
<Group justify="space-between">
<Button styles={secondaryStyles} onClick={onClose}>
{secondaryLabel}
</Button>
<Button styles={primaryStyles} onClick={handleSeePlans}>
{primaryLabel}
</Button>
</Group>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,22 @@
import React from 'react';
import { useTour } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
export default function TourContent() {
const { isOpen } = useOnboarding();
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(isOpen);
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
previousIsOpenRef.current = isOpen;
if (wasClosedNowOpen) {
setCurrentStep(0);
}
setIsOpen(isOpen);
}, [isOpen, setIsOpen, setCurrentStep]);
return null;
}
@@ -0,0 +1,82 @@
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface TourWelcomeModalProps {
opened: boolean;
onStartTour: () => void;
onMaybeLater: () => void;
onDontShowAgain: () => void;
}
export default function TourWelcomeModal({
opened,
onStartTour,
onMaybeLater,
onDontShowAgain,
}: TourWelcomeModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onMaybeLater}
centered
size="md"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="lg">
<Stack gap="xs">
<Title order={2}>
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
</Title>
<Text size="md" c="dimmed">
{t('onboarding.welcomeModal.description',
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
)}
</Text>
<Text
size="md"
c="dimmed"
dangerouslySetInnerHTML={{
__html: t('onboarding.welcomeModal.helpHint',
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
)
}}
/>
</Stack>
<Stack gap="sm">
<Button
onClick={onStartTour}
size="md"
variant="filled"
fullWidth
>
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
</Button>
<Group grow>
<Button
onClick={onMaybeLater}
size="md"
variant="light"
>
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
</Button>
<Button
onClick={onDontShowAgain}
size="md"
variant="light"
>
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
);
}
@@ -1,19 +1,8 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
export enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import type { LicenseNotice } from '@app/types/types';
import { useNavigate, useLocation } from 'react-router-dom';
import {
ONBOARDING_SESSION_BLOCK_KEY,
ONBOARDING_SESSION_EVENT,
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
} from '@app/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
// Auth routes where onboarding should NOT show
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('stirling_jwt');
}
interface InitialModalHandlers {
opened: boolean;
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
onClose: () => void;
}
interface ServerLicenseModalHandlers {
opened: boolean;
licenseNotice: LicenseNotice;
onClose: () => void;
onSeePlans: () => void;
}
export function useOnboardingFlow() {
const { preferences, updatePreference } = usePreferences();
const { config, loading: configLoading } = useAppConfig();
const { completeTour, tourType, isOpen } = useOnboarding();
const location = useLocation();
// Check if we're on an auth route (login, signup, etc.)
const isOnAuthRoute = AUTH_ROUTES.some(route => location.pathname.startsWith(route));
// Check if login is enabled but user doesn't have a token
// This prevents a flash of the modal before redirect to /login
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
// Don't show intro onboarding:
// 1. On explicit auth routes (/login, /signup, etc.)
// 2. While config is still loading
// 3. When login is enabled but user isn't authenticated (would redirect to /login)
// This ensures:
// - If login is enabled: user must be logged in before seeing onboarding
// - If login is disabled: homepage must have rendered first
const shouldShowIntro = !preferences.hasSeenIntroOnboarding
&& !isOnAuthRoute
&& !configLoading
&& !isUnauthenticatedWithLoginEnabled;
const isAdminUser = !!config?.isAdmin;
const { hasPaidLicense } = useServerExperience();
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
});
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
const [toolPromptCompleted, setToolPromptCompleted] = useState(
preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
);
const introWasOpenRef = useRef(false);
const navigate = useNavigate();
const onboardingSessionMarkedRef = useRef(false);
const handleInitialModalClose = useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
}, [preferences.hasSeenIntroOnboarding, updatePreference]);
const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
setLicenseNotice(notice);
}, []);
const handleToolPromptComplete = useCallback(() => {
setToolPromptCompleted(true);
}, []);
const requestServerLicense = useCallback(
({
deferUntilTourComplete = false,
selfReportedAdmin = false,
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
const qualifies = isAdminUser || selfReportedAdmin;
if (!qualifies) {
return;
}
if (hasPaidLicense || !licenseNotice.requiresLicense) {
return;
}
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
setServerLicenseIntent((prev) => {
if (prev === 'pending') {
return prev;
}
if (prev === 'deferred') {
return deferUntilTourComplete ? prev : 'pending';
}
if (prev === 'idle') {
return deferUntilTourComplete ? 'deferred' : 'pending';
}
return prev;
});
},
[hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleServerLicenseRequested = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice((prev) => ({
...prev,
...detail.licenseNotice,
totalUsers:
detail.licenseNotice?.totalUsers ?? prev.totalUsers,
freeTierLimit:
detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
isOverLimit:
detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
requiresLicense:
detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
}));
}
requestServerLicense({
deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
selfReportedAdmin: detail?.selfReportedAdmin ?? false,
});
};
window.addEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleServerLicenseRequested as EventListener,
);
return () => {
window.removeEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleServerLicenseRequested as EventListener,
);
};
}, [requestServerLicense]);
useEffect(() => {
const isEligibleAdmin =
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
if (
introWasOpenRef.current &&
!shouldShowIntro &&
isEligibleAdmin &&
toolPromptCompleted &&
!hasShownServerLicense &&
licenseNotice.requiresLicense &&
serverLicenseIntent === 'idle'
) {
if (!serverLicenseSource) {
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
}
setServerLicenseIntent('pending');
}
introWasOpenRef.current = shouldShowIntro;
}, [
hasShownServerLicense,
isAdminUser,
serverLicenseIntent,
shouldShowIntro,
serverLicenseSource,
toolPromptCompleted,
licenseNotice.requiresLicense,
]);
useEffect(() => {
const isEligibleAdmin =
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
if (
serverLicenseIntent !== 'idle' &&
!shouldShowIntro &&
!isOpen &&
!isServerLicenseOpen &&
isEligibleAdmin &&
toolPromptCompleted &&
licenseNotice.requiresLicense
) {
setIsServerLicenseOpen(true);
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
}
}, [
isAdminUser,
isOpen,
isServerLicenseOpen,
serverLicenseIntent,
shouldShowIntro,
serverLicenseSource,
toolPromptCompleted,
licenseNotice.requiresLicense,
]);
const handleServerLicenseClose = useCallback(() => {
setIsServerLicenseOpen(false);
setHasShownServerLicense(true);
setServerLicenseIntent('idle');
setServerLicenseSource(null);
}, []);
useEffect(() => {
if (onboardingSessionMarkedRef.current) {
return;
}
if (typeof window === 'undefined') {
return;
}
if (shouldShowIntro || isOpen) {
onboardingSessionMarkedRef.current = true;
window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
}
}, [isOpen, shouldShowIntro]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (!shouldShowIntro && !isOpen) {
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
}
}, [isOpen, shouldShowIntro]);
const handleServerLicenseSeePlans = useCallback(() => {
handleServerLicenseClose();
navigate('/settings/adminPlan');
}, [handleServerLicenseClose, navigate]);
const handleTourCompletion = useCallback(() => {
completeTour();
if (serverLicenseIntent === 'deferred') {
setServerLicenseIntent('pending');
} else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
}
}, [
completeTour,
isAdminUser,
serverLicenseIntent,
serverLicenseSource,
tourType,
]);
const initialModalProps: InitialModalHandlers = useMemo(
() => ({
opened: shouldShowIntro,
onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
onRequestServerLicense: requestServerLicense,
onClose: handleInitialModalClose,
}),
[handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
);
const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
() => ({
opened: isServerLicenseOpen,
licenseNotice,
onClose: handleServerLicenseClose,
onSeePlans: handleServerLicenseSeePlans,
}),
[handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
);
return {
tourType,
isTourOpen: isOpen,
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
initialModalProps,
handleToolPromptComplete,
serverLicenseModalProps,
handleTourCompletion,
};
}
@@ -3,18 +3,16 @@ import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstal
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
export type ButtonAction =
| 'next'
@@ -48,10 +46,6 @@ export interface SlideFactoryParams {
onRoleSelect: (role: 'admin' | 'user' | null) => void;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
// First login params
firstLoginUsername?: string;
onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean;
}
export interface HeroDefinition {
@@ -77,17 +71,6 @@ export interface SlideDefinition {
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': {
id: 'first-login',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
hero: { type: 'lock' },
buttons: [], // Form has its own submit button
},
'welcome': {
id: 'welcome',
createSlide: () => WelcomeSlide(),
@@ -214,3 +197,11 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
};
export const FLOW_SEQUENCES = {
loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
loginUser: ['welcome', 'desktop-install'] as SlideId[],
noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
noLoginAdmin: ['admin-overview'] as SlideId[],
};
@@ -1,127 +0,0 @@
export type OnboardingStepId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'tool-layout'
| 'tour'
| 'server-license'
| 'analytics-choice';
export type OnboardingStepType =
| 'modal-slide'
| 'tool-prompt'
| 'tour'
| 'analytics-modal';
export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null;
tourRequested: boolean;
tourType: 'admin' | 'tools';
isDesktopApp: boolean;
analyticsNotConfigured: boolean;
analyticsEnabled: boolean;
licenseNotice: {
totalUsers: number | null;
freeTierLimit: number;
isOverLimit: boolean;
requiresLicense: boolean;
};
requiresPasswordChange: boolean;
firstLoginUsername: string;
usingDefaultCredentials: boolean;
}
export interface OnboardingConditionContext extends OnboardingRuntimeState {
loginEnabled: boolean;
effectiveIsAdmin: boolean;
}
export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: 'tools',
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
licenseNotice: {
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
},
requiresPasswordChange: false,
firstLoginUsername: '',
usingDefaultCredentials: false,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'first-login',
type: 'modal-slide',
slideId: 'first-login',
condition: (ctx) => ctx.requiresPasswordChange,
},
{
id: 'welcome',
type: 'modal-slide',
slideId: 'welcome',
condition: () => true,
},
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
},
{
id: 'admin-overview',
type: 'modal-slide',
slideId: 'admin-overview',
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: 'tool-layout',
type: 'tool-prompt',
condition: () => true,
},
{
id: 'tour',
type: 'tour',
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
},
{
id: 'server-license',
type: 'modal-slide',
slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'analytics-choice',
type: 'analytics-modal',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
return ONBOARDING_STEPS.find((step) => step.id === id);
}
export function getStepIndex(id: OnboardingStepId): number {
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
}
@@ -1,97 +0,0 @@
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
const STORAGE_PREFIX = 'onboarding';
export function getStorageKey(stepId: OnboardingStepId): string {
return `${STORAGE_PREFIX}::${stepId}`;
}
export function hasSeenStep(stepId: OnboardingStepId): boolean {
if (typeof window === 'undefined') return false;
try {
return localStorage.getItem(getStorageKey(stepId)) === 'true';
} catch {
return false;
}
}
export function markStepSeen(stepId: OnboardingStepId): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(getStorageKey(stepId), 'true');
} catch (error) {
console.error('[onboardingStorage] Error marking step as seen:', error);
}
}
export function resetStepSeen(stepId: OnboardingStepId): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(getStorageKey(stepId));
} catch (error) {
console.error('[onboardingStorage] Error resetting step seen:', error);
}
}
export function resetAllOnboardingProgress(): void {
if (typeof window === 'undefined') return;
try {
const prefix = `${STORAGE_PREFIX}::`;
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(prefix)) keysToRemove.push(key);
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
}
}
export function getOnboardingStorageState(): Record<string, boolean> {
const state: Record<string, boolean> = {};
ONBOARDING_STEPS.forEach((step) => {
state[step.id] = hasSeenStep(step.id);
});
return state;
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// Migrate based on legacy flags
if (prefs.hasSeenIntroOnboarding === true) {
markStepSeen('welcome');
markStepSeen('desktop-install');
markStepSeen('security-check');
markStepSeen('admin-overview');
}
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
markStepSeen('tool-layout');
}
if (prefs.hasCompletedOnboarding === true) {
markStepSeen('tour');
markStepSeen('analytics-choice');
markStepSeen('server-license');
}
}
// Mark migration complete
localStorage.setItem(migrationKey, 'true');
} catch {
// If migration fails, onboarding will show again - safer than hiding it
}
}
@@ -1,353 +0,0 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import {
ONBOARDING_STEPS,
type OnboardingStepId,
type OnboardingStep,
type OnboardingRuntimeState,
type OnboardingConditionContext,
DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig';
import {
hasSeenStep,
markStepSeen,
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
const SESSION_TOUR_TYPE = 'onboarding::session::tour-type';
const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role';
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('stirling_jwt');
}
// Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
if (typeof window === 'undefined') {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
return {
...baseState,
tourRequested,
tourType,
selectedRole,
};
} catch {
return baseState;
}
}
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
}
if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
}
if (state.selectedRole !== undefined) {
if (state.selectedRole) {
sessionStorage.setItem(SESSION_SELECTED_ROLE, state.selectedRole);
} else {
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
}
}
} catch (error) {
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
}
}
function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
sessionStorage.removeItem(SESSION_TOUR_TYPE);
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
} catch {
// Ignore errors
}
}
export interface OnboardingOrchestratorState {
/** Whether onboarding is currently active */
isActive: boolean;
/** The current step being shown (null if no step is active) */
currentStep: OnboardingStep | null;
/** Index of current step in the active flow (for display purposes) */
currentStepIndex: number;
/** Total number of steps in the active flow */
totalSteps: number;
/** Runtime state that affects conditions */
runtimeState: OnboardingRuntimeState;
/** All steps that will be shown in this flow (filtered by conditions) */
activeFlow: OnboardingStep[];
/** Whether all steps have been seen */
isComplete: boolean;
/** Whether we're still initializing */
isLoading: boolean;
}
export interface OnboardingOrchestratorActions {
/** Move to the next step */
next: () => void;
/** Move to the previous step */
prev: () => void;
/** Skip the current step (marks as seen but doesn't complete) */
skip: () => void;
/** Mark current step as seen and move to next */
complete: () => void;
/** Update runtime state (e.g., after role selection) */
updateRuntimeState: (updates: Partial<OnboardingRuntimeState>) => void;
/** Force re-evaluation of the flow (used when conditions change) */
refreshFlow: () => void;
/** Manually start a specific step (for external triggers) */
startStep: (stepId: OnboardingStepId) => void;
/** Close/pause onboarding (can be resumed later) */
pause: () => void;
/** Resume onboarding from where it was paused */
resume: () => void;
}
export interface UseOnboardingOrchestratorResult {
state: OnboardingOrchestratorState;
actions: OnboardingOrchestratorActions;
}
export interface UseOnboardingOrchestratorOptions {
/** Override the default runtime state (used by desktop to set isDesktopApp: true) */
defaultRuntimeState?: OnboardingRuntimeState;
}
export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions
): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
);
const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
const migrationDone = useRef(false);
const initialIndexSet = useRef(false);
useEffect(() => {
if (!migrationDone.current) {
migrateFromLegacyPreferences();
migrationDone.current = true;
}
}, []);
useEffect(() => {
setRuntimeState((prev) => ({
...prev,
analyticsEnabled: config?.enableAnalytics === true,
analyticsNotConfigured: config?.enableAnalytics == null,
licenseNotice: {
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
isOverLimit: serverExperience.overFreeTierLimit ?? false,
requiresLicense: !serverExperience.hasPaidLicense && (
serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
),
},
}));
}, [
config?.enableAnalytics,
serverExperience.totalUsers,
serverExperience.freeTierLimit,
serverExperience.overFreeTierLimit,
serverExperience.hasPaidLicense,
serverExperience.effectiveIsAdmin,
serverExperience.userCountResolved,
]);
useEffect(() => {
const checkFirstLogin = async () => {
if (config?.enableLogin !== true || !hasAuthToken()) return;
try {
const [accountData, loginPageData] = await Promise.all([
accountService.getAccountData(),
accountService.getLoginPageData(),
]);
setRuntimeState((prev) => ({
...prev,
requiresPasswordChange: accountData.changeCredsFlag,
firstLoginUsername: accountData.username,
usingDefaultCredentials: loginPageData.showDefaultCredentials,
}));
} catch {
// Account endpoint failed - user not logged in or security disabled
}
};
if (!configLoading) {
checkFirstLogin();
}
}, [config?.enableLogin, configLoading]);
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => {
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined
);
useEffect(() => {
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
let firstUnseenIndex = -1;
for (let i = 0; i < activeFlow.length; i++) {
if (!hasSeenStep(activeFlow[i].id)) {
firstUnseenIndex = i;
break;
}
}
if (firstUnseenIndex === -1) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
} else if (!initialIndexSet.current) {
setCurrentStepIndex(firstUnseenIndex);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved]);
const totalSteps = activeFlow.length;
const allStepsAlreadySeen = useMemo(() => {
if (activeFlow.length === 0) return false;
return activeFlow.every(step => hasSeenStep(step.id));
}, [activeFlow]);
const isComplete = isInitialized && initialIndexSet.current &&
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
? activeFlow[currentStepIndex]
: null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
if (!configLoading && !isInitialized) setIsInitialized(true);
}, [configLoading, isInitialized]);
useEffect(() => {
if (isComplete) clearRuntimeStateSession();
}, [isComplete]);
const next = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
const prev = useCallback(() => {
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
}, []);
const skip = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
const complete = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
useEffect(() => {
if (!currentStep || isLoading) {
return;
}
if (hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
}, []);
const refreshFlow = useCallback(() => {
initialIndexSet.current = false;
setCurrentStepIndex(-1);
}, []);
const startStep = useCallback((stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
}, [activeFlow]);
const pause = useCallback(() => setIsPaused(true), []);
const resume = useCallback(() => setIsPaused(false), []);
const state: OnboardingOrchestratorState = {
isActive,
currentStep,
currentStepIndex,
totalSteps,
runtimeState,
activeFlow,
isComplete,
isLoading,
};
const actions: OnboardingOrchestratorActions = {
next,
prev,
skip,
complete,
updateRuntimeState,
refreshFlow,
startStep,
pause,
resume,
};
return { state, actions };
}
@@ -1,184 +0,0 @@
import React, { useState } from 'react';
import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { accountService } from '@app/services/accountService';
import { alert as showToast } from '@app/components/toast';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface FirstLoginSlideProps {
username: string;
onPasswordChanged: () => void;
usingDefaultCredentials?: boolean;
}
const DEFAULT_PASSWORD = 'stirling';
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : '');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
// Validation
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
return;
}
try {
setLoading(true);
setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword);
showToast({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
// Wait a moment for the user to see the success message
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err) {
console.error('Failed to change password:', err);
// Extract error message from axios response if available
const axiosError = err as { response?: { data?: { message?: string } } };
setError(
axiosError.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
);
} finally {
setLoading(false);
}
};
return (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: '#3B82F6', flexShrink: 0 }} />
<span>
{t(
'firstLogin.welcomeMessage',
'For security reasons, you must change your password on your first login.'
)}
</span>
</div>
<Text size="sm" fw={500}>
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
</Text>
{error && (
<Alert
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
color="red"
variant="light"
>
{error}
</Alert>
)}
{/* Only show current password field if not using default credentials */}
{!usingDefaultCredentials && (
<PasswordInput
label={t('firstLogin.currentPassword', 'Current Password')}
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
)}
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
<Button
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!newPassword || !confirmPassword}
size="md"
mt="xs"
>
{t('firstLogin.changePassword', 'Change Password')}
</Button>
</Stack>
</div>
</div>
);
}
export default function FirstLoginSlide({
username,
onPasswordChanged,
usingDefaultCredentials = false,
}: FirstLoginSlideProps): SlideConfig {
return {
key: 'first-login',
title: 'Set Your Password',
body: (
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
usingDefaultCredentials={usingDefaultCredentials}
/>
),
background: {
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,33 @@
export enum TourStep {
ALL_TOOLS,
SELECT_CROP_TOOL,
TOOL_INTERFACE,
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
FILE_REPLACEMENT,
PIN_BUTTON,
WRAP_UP,
}
export enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
@@ -1,79 +0,0 @@
/**
* useOnboardingDownload Hook
*
* Encapsulates OS detection and download URL logic for the desktop install slide.
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useOs } from '@app/hooks/useOs';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface OsInfo {
label: string;
url: string;
}
interface OsOption {
label: string;
url: string;
value: string;
}
interface UseOnboardingDownloadResult {
osInfo: OsInfo;
osOptions: OsOption[];
selectedDownloadUrl: string;
setSelectedDownloadUrl: (url: string) => void;
handleDownloadSelected: () => void;
}
export function useOnboardingDownload(): UseOnboardingDownloadResult {
const osType = useOs();
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>('');
const osInfo = useMemo<OsInfo>(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo<OsOption[]>(() => [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
].filter((opt) => opt.url), []);
// Initialize selected URL from detected OS
useEffect(() => {
if (!selectedDownloadUrl && osInfo.url) {
setSelectedDownloadUrl(osInfo.url);
}
}, [osInfo.url, selectedDownloadUrl]);
const handleDownloadSelected = useCallback(() => {
const downloadUrl = selectedDownloadUrl || osInfo.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
}, [selectedDownloadUrl, osInfo.url]);
return {
osInfo,
osOptions,
selectedDownloadUrl,
setSelectedDownloadUrl,
handleDownloadSelected,
};
}
@@ -1,74 +0,0 @@
import { useEffect, useCallback, useState } from 'react';
import {
SERVER_LICENSE_REQUEST_EVENT,
START_TOUR_EVENT,
type ServerLicenseRequestPayload,
type TourType,
type StartTourPayload,
} from '@app/constants/events';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
export function useServerLicenseRequest(): {
showLicenseSlide: boolean;
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleLicenseRequest = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice({
totalUsers: detail.licenseNotice.totalUsers ?? null,
freeTierLimit: detail.licenseNotice.freeTierLimit ?? 5,
isOverLimit: detail.licenseNotice.isOverLimit ?? false,
requiresLicense: true,
});
}
setShowLicenseSlide(true);
};
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
}, []);
const closeLicenseSlide = useCallback(() => {
setShowLicenseSlide(false);
}, []);
return { showLicenseSlide, licenseNotice, closeLicenseSlide };
}
export function useTourRequest(): {
tourRequested: boolean;
requestedTourType: TourType;
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'tools');
setTourRequested(true);
};
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
}, []);
const clearTourRequest = useCallback(() => {
setTourRequested(false);
}, []);
return { tourRequested, requestedTourType, clearTourRequest };
}
@@ -1,26 +1,6 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
export enum TourStep {
ALL_TOOLS,
SELECT_CROP_TOOL,
TOOL_INTERFACE,
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
FILE_REPLACEMENT,
PIN_BUTTON,
WRAP_UP,
}
import { TourStep } from '@app/components/onboarding/tourSteps';
interface UserStepActions {
saveWorkbenchState: () => void;
@@ -7,7 +7,7 @@ import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
@@ -122,7 +122,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_CONFIG_MODAL}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
@@ -130,6 +130,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
styles={{
label: {
color: textColor ?? toneStyle.text,
},
}}
>
{buttonText}
</Button>

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