Compare commits

..
10 Commits
Author SHA1 Message Date
Reece 19e611bbcf lint and search improvents 2025-12-10 16:38:46 +00:00
Reece 6eb6af9445 Search on ctrl f 2025-12-10 16:23:15 +00:00
Anthony StirlingandGitHub f3cc30d0c2 Update wording for third-party services reference 2025-12-02 19:14:03 +00:00
Reece BrowneandGitHub ba7c75aff4 Update embed and allow form rendering (#5124)
You can now see the values of forms filled elsewhre (still no filling
forms ourselves)
2025-12-02 18:56:22 +00:00
Anthony StirlingandGitHub a53d73ef51 Revise README for improved structure and clarity (#5121)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-02 17:25:59 +00:00
c2a63cf425 java frontend (#5097)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Reece <reece@stirlingpdf.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-12-02 17:15:29 +00:00
Reece BrowneandGitHub c3456adc2b Print with embed (#5109) 2025-12-02 13:56:28 +00:00
EthanHealy01andGitHub 179b569769 Chore/v2/onboarding flow cleanup (#5065) 2025-12-02 12:40:20 +00:00
Dario Ghunney WareGitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>LudyEthanHealy01EthanAnthony Stirlingstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
341adaa07d Grandpa Fix (#5030)
PR to address inactive accounts (invited/pending activation) not being
grandfathered during migration

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-02 12:34:38 +00:00
Dario Ghunney WareGitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>LudyEthanHealy01EthanAnthony Stirlingstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
feebfe82fa Reduce JWT Logs (#5108)
Removed logging in some areas and changed level from `WARN` -> `DEBUG`
to reduce verbosity

Closes #5089

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-02 12:34:17 +00:00
81 changed files with 2686 additions and 2554 deletions
+1
View File
@@ -5,6 +5,7 @@ frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts
.gradle
@@ -180,7 +180,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
+2 -2
View File
@@ -262,7 +262,7 @@ jobs:
strategy:
fail-fast: false
matrix:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -301,7 +301,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/backend/${{ matrix.docker-rev }}
file: ./${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
+10 -9
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- V2-master
- alljavadocker
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -93,10 +94,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (V2-demo branch - test)
- name: Generate tags for latest (alljavadocker branch - test)
id: meta-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -110,7 +111,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -149,10 +150,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (V2-demo branch - test)
- name: Generate tags for latest-fat (alljavadocker branch - test)
id: meta-fat-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -166,7 +167,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -203,10 +204,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Generate tags for ultra-lite (V2-demo branch - test)
- name: Generate tags for ultra-lite (alljavadocker branch - test)
id: meta-lite-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -220,7 +221,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified-lite
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+3 -3
View File
@@ -107,7 +107,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -152,7 +152,7 @@ jobs:
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./Dockerfile.ultra-lite
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -183,7 +183,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile.fat
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
@@ -39,6 +39,7 @@ public class RequestUriUtils {
// Specific static files bundled with the frontend
if (normalizedUri.equals("/robots.txt")
|| normalizedUri.equals("/favicon.ico")
|| normalizedUri.equals("/manifest.json")
|| normalizedUri.equals("/site.webmanifest")
|| normalizedUri.equals("/manifest-classic.json")
|| normalizedUri.equals("/index.html")) {
@@ -1,20 +1,61 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
@Controller
public class ReactRoutingController {
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
@Value("${server.servlet.context-path:/}")
private String contextPath;
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
throws IOException {
ClassPathResource resource = new ClassPathResource("static/index.html");
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(html);
}
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
}
}
@@ -19,9 +19,9 @@ 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.
* 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
@@ -324,10 +324,14 @@ public class SecurityConfiguration {
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
// We configure form login to handle Spring Security redirects,
// but use /perform_login as the processing URL so /login remains a React route
http.formLogin(
formLogin ->
formLogin
.loginPage("/login")
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl("/perform_login") // Process form posts here (not /login)
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
@@ -283,7 +283,12 @@ public class AdminLicenseController {
// 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 '..'"));
.body(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
}
// Validate file extension
@@ -56,6 +56,19 @@ 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,22 +105,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
log.warn("JWT validation failed: {}", e.getMessage());
log.debug("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(
@@ -50,7 +50,6 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -256,11 +255,9 @@ 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;
}
@@ -283,10 +280,9 @@ public class JwtService implements JwtServiceInterface {
.parse(token)
.getHeader()
.get("kid");
log.debug("Extracted key ID from token: {}", keyId);
return keyId;
} catch (Exception e) {
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
return null;
}
}
@@ -55,7 +55,6 @@ public class KeyPairCleanupService {
return;
}
log.info("Removing keys older than retention period");
removeKeys(eligibleKeys);
keyPersistenceService.refreshActiveKeyPair();
}
@@ -778,4 +778,30 @@ 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;
}
}
@@ -192,10 +192,18 @@ public class UserLicenseSettingsService {
+ "They will retain OAuth access even without a paid license. "
+ "New users will require a paid license for OAuth.",
updated);
} else if (grandfatheredCount > 0) {
log.debug(
"OAuth grandfathering already completed: {} users grandfathered",
grandfatheredCount);
}
// 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);
}
}
}
}
@@ -2,6 +2,9 @@ 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;
@@ -198,4 +201,70 @@ 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();
}
}
+138
View File
@@ -0,0 +1,138 @@
# Stirling-PDF Dockerfile - Full version with embedded frontend
# Single JAR contains both frontend and backend
# Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21 AS build
# Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Full version"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, API, Spring Boot, React"
# Copy scripts and fonts
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# Copy built JAR from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Install all dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
ghostscript \
fontforge \
# pdftohtml
poppler-utils \
# OCR MY PDF
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+142
View File
@@ -0,0 +1,142 @@
# Stirling-PDF Dockerfile - Fat version with embedded frontend
# Single JAR contains both frontend and backend with extra fonts for air-gapped environments
# Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21 AS build
# Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF Fat"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Fat version with extra fonts for air-gapped environments"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, fat, air-gapped, API, Spring Boot, React"
# Copy scripts and fonts
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# Copy built JAR from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
FAT_DOCKER=true \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Install all dependencies plus extra fonts for air-gapped environments
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
ghostscript \
fontforge \
# pdftohtml
poppler-utils \
# OCR MY PDF
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
# Extra fonts for fat version
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+104
View File
@@ -0,0 +1,104 @@
# Stirling-PDF Dockerfile - Ultra-lite version with embedded frontend
# Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21 AS build
# Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features)
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spring Boot, React"
# Copy scripts
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
# Copy built JAR from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf \
ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openjdk21-jre && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
mkdir -p /usr/share/fonts/opentype/noto && \
chmod +x /scripts/*.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
+4 -4
View File
@@ -3,21 +3,21 @@
<head>
<meta charset="UTF-8" />
<base href="%BASE_URL%" />
<link rel="icon" href="/modern-logo/favicon.ico" />
<link rel="icon" href="modern-logo/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/>
<link rel="apple-touch-icon" href="/modern-logo/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
<link rel="manifest" href="manifest.json" />
<title>Stirling PDF</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="src/index.tsx"></script>
</body>
</html>
+176 -183
View File
@@ -11,25 +11,26 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.5.0",
"@embedpdf/engines": "^1.5.0",
"@embedpdf/plugin-annotation": "^1.5.0",
"@embedpdf/plugin-bookmark": "^1.5.0",
"@embedpdf/plugin-export": "^1.5.0",
"@embedpdf/plugin-history": "^1.5.0",
"@embedpdf/plugin-interaction-manager": "^1.5.0",
"@embedpdf/plugin-loader": "^1.5.0",
"@embedpdf/plugin-pan": "^1.5.0",
"@embedpdf/plugin-print": "^1.5.0",
"@embedpdf/plugin-render": "^1.5.0",
"@embedpdf/plugin-rotate": "^1.5.0",
"@embedpdf/plugin-scroll": "^1.5.0",
"@embedpdf/plugin-search": "^1.5.0",
"@embedpdf/plugin-selection": "^1.5.0",
"@embedpdf/plugin-spread": "^1.5.0",
"@embedpdf/plugin-thumbnail": "^1.5.0",
"@embedpdf/plugin-tiling": "^1.5.0",
"@embedpdf/plugin-viewport": "^1.5.0",
"@embedpdf/plugin-zoom": "^1.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -455,7 +456,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -499,7 +499,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -576,14 +575,13 @@
"license": "0BSD"
},
"node_modules/@embedpdf/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -594,13 +592,13 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.4.1.tgz",
"integrity": "sha512-yugIb5OwTI/1VnAaEvSYxAd2DvYBPkV/D7wytagyaOq98o3sqzcY2Q9zHt+LhnawA5KKG1e/FDPjCd4qm8gsvg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.5.0.tgz",
"integrity": "sha512-/GzhjHFHWfOaX7vjgFJX/pyq668wYjoda1bZ9MpwF/EF000Wwy2Q0AOhprjldPFz8ASKjwKwqsXmaqrK99yOAQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/pdfium": "1.4.1"
"@embedpdf/models": "1.5.0",
"@embedpdf/pdfium": "1.5.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -611,31 +609,31 @@
}
},
"node_modules/@embedpdf/models": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.4.1.tgz",
"integrity": "sha512-2nTg8Q1qpplBvspZJXMCZOA+/OILpfdNRPddlplxZXY/Upx0rzKXx/e6pXWW7AuOgtfGneT4h9tMs3A595/PdQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.5.0.tgz",
"integrity": "sha512-x/1li3jdag+IzfZkcfRLKLqASLep4v6dgVi3z0JArwaicFra8k1IY2xaVTrwcZyx7pRb/rxvoO9yLHW0Y34NFw==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.4.1.tgz",
"integrity": "sha512-BekKEK4UNCwzj7xOffKn6WpL0FQHxq+mTj2iGI3N7OwAX2J/BO2G+rDOB+lvojQG+Dkpg8uqm427ZKJDRyLgVQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.5.0.tgz",
"integrity": "sha512-PI32t2U4ThZC907n2Iwr8E5WqmC574G83u3V9ysNFl29N9kasrY9RiLSzU4W/yQvXPjIbpQHBsbMKXLjCFBI9w==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.4.1.tgz",
"integrity": "sha512-d4HibNy6ecyDqx2Y2R8VjaqppSdjNofAJmU6VenOd88wn080sAUqvnkeVJ6ehJH5BoND4ymQrcAkcbVeYK0myA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.5.0.tgz",
"integrity": "sha512-mxEPI6xYwOGaf9fYfoywuj6nwA10eHFPBuN066MzwphDk6DOHJGZ3Vq8zNQBXh20c/Lb25PL718D7MZWxZLUHg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/utils": "1.4.1"
"@embedpdf/models": "1.5.0",
"@embedpdf/utils": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-history": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-selection": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-history": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-selection": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -643,15 +641,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.5.0.tgz",
"integrity": "sha512-s3C9PtVesy5X8Ds/C9TEElFiqfKGRklG/uNPTROpNoolfpi0h7qX2xqqh/9+FzKH2nHjVcPB7Pp432v16h7eRA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -659,15 +657,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
"integrity": "sha512-g89fREFM/zkt2Ai2Q5dWwDkhXgC/JmVyUniaMgm1fTG/MZ0Z05E7f34DUzX/CKcJyVjxEgl6tojBTMeUbm15bA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.5.0.tgz",
"integrity": "sha512-luk68mNW9l2X31qk4b02phKaqDl9aDXUAgHVz1EWrgwXQ3Oz9WEdu60utYARYDiepDo3Caadll8RwctYSf/anA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -676,16 +674,15 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -693,16 +690,15 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -711,16 +707,15 @@
}
},
"node_modules/@embedpdf/plugin-loader": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -729,17 +724,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.4.1.tgz",
"integrity": "sha512-zmOZJ9dUqXiaV0F5GPf/5WTWf3jAEkiv153Tl3x8HT9Rfff+WQhV48NruCIBAy/T4jVt4aH7D1zt/B/ftvcdkA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.5.0.tgz",
"integrity": "sha512-EMQ08dHqLkZmFVuLOO6h3AAinFPQoA1r6OlL9z+p0sswq31JAgd4X7+xjYIpI01z/V3+cTzPHzp7qwob5E4tbA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -747,17 +742,33 @@
"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",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"node_modules/@embedpdf/plugin-print": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.5.0.tgz",
"integrity": "sha512-rjorvNxAZfO9X4cFZVU9fHnldMWqMceJGmr3mH+yj7KdHePvNDDP+omyZyZKtxlUZENaeDI2h6k5z0GbhBz6sQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"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.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -766,15 +777,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.4.1.tgz",
"integrity": "sha512-hVzHkKwMNH3tUhxqJGsj5qTLpYZXbj6E74AEcG0w/fz5FrK7EnofPqt0gRfYmIzxnQGIh+39BRtcp8gmx8UNnw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.5.0.tgz",
"integrity": "sha512-5EmBCsq0VfrE3xWY6ofuVm8S6aK95EbAycRIk1wczcmTdvpsuXZ6P2ZaECUgYMcpZ6uAg4/kGf8X8VVZuCihSQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -783,17 +794,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -802,16 +812,16 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.4.1.tgz",
"integrity": "sha512-8JG4CbOcUsLuT0vHJJ4cECmu+Yn53EokWFUVXi2Mo/XvHjhrQuWmD7+y6s/qQPEpctFYWmUCXTDAX9ynPud+2Q==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.5.0.tgz",
"integrity": "sha512-TB5b0H8Iobx/azVUBIlG2ClaKtf0y3/Xi3E/iB8BwvkIE2+g6EGfp8IMXIn8WDXST6bbvJEP31Ab0Ilp6SVkiw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-loader": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-loader": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -820,18 +830,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -840,16 +849,16 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.4.1.tgz",
"integrity": "sha512-l+SrDVGTiiItkt2cEtzv7V/X5HhmLbYHcQ8CFobGeIKdJtzKS1Nu/JSKqg7Ki7eCNgyPL1yMNfNE92bNKYVN4w==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.5.0.tgz",
"integrity": "sha512-3EU5Cp+fPQSiMjvMR/P2kXxXry/RlnxHLs4JeskAaH95QcqWW3VD+DrHkWSiLFkdhI18rNNGNlMc5RvDGvbXGQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-loader": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-loader": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -858,16 +867,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.4.1.tgz",
"integrity": "sha512-bN3msjI0PovazgbPK3LyugYVTwIDo0RyBUhBaG42FgJxeY3hmFOWTPgfUH1QF7twHlySnksIvHRFYR3nViryVw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.5.0.tgz",
"integrity": "sha512-Z2qpyyr5s2M6460KDGu1Vk6rdbQFIoCpnyFAT6e7UaTIKkqJSNpmjqMsBU5PosYCFu/cClpHPvS7tg9/IKAk6g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-render": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-render": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -876,18 +885,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.4.1.tgz",
"integrity": "sha512-wgTfj5T8HV6KP61iiR63DVNrbVp8sPxTqa1Sm+2/D0jY+EPSSCmpt1/qYWiAXd1X+t78foOjCnfbo7fEMn5/pg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.5.0.tgz",
"integrity": "sha512-0Vx9elHNpMM+zv8hEoZXBEm8Q0+4kU52LxOlTYRr1A5FskF836sUct6g1ngwK1bmfbAfpz+62PnYI2EeilDZig==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-render": "1.4.1",
"@embedpdf/plugin-scroll": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-render": "1.5.0",
"@embedpdf/plugin-scroll": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -896,16 +905,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -914,19 +922,19 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.4.1.tgz",
"integrity": "sha512-9HocmXnPZxqN06q7kyNAmLjgDHOEW8/8QfgNE3nMpRyNHIgnAjxvsWc9lApgp5ErDPG0cSDt0Cduil6nB3wSBQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.5.0.tgz",
"integrity": "sha512-LiDkCd5/IXg2CRORl1Yikan2op+AYXSxhHzCFatyBdwzVj+n4y9I74OwCI62Mar8WDAIMyXZDCQxGPToSm+zDw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/models": "1.5.0",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-scroll": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-scroll": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -935,9 +943,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.4.1.tgz",
"integrity": "sha512-vvJ51Qsz3PyJWR2YvDMMpJXg4+YqdV7Vn2cusmW9sx+4EnAiBiw0HevEE+FepgFV8k+A0WbwXzmsujDIQJ7R4A==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.5.0.tgz",
"integrity": "sha512-L6jsAPQPGM8ne+MMFAd5gqXb1RNEgNyh16VvVUVKcVnJlBhwil59nVeEQ0cwPhjF5qVeY6MQDIOjBzJqkgXOYg==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
@@ -1056,7 +1064,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1100,7 +1107,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2131,7 +2137,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2182,7 +2187,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2250,7 +2254,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3183,7 +3186,6 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3302,6 +3304,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4078,7 +4081,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4407,7 +4409,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4418,7 +4419,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4488,7 +4488,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5202,6 +5201,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5211,6 +5211,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5221,6 +5222,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5233,6 +5235,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5259,7 +5262,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5667,6 +5669,7 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5943,7 +5946,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6991,8 +6993,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7387,7 +7388,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7558,7 +7558,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7725,7 +7724,8 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7790,6 +7790,7 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8880,7 +8881,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9357,6 +9357,7 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9677,7 +9678,6 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10264,7 +10264,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -11410,7 +11411,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11690,7 +11690,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12073,7 +12072,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12083,7 +12081,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13595,6 +13592,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13803,7 +13801,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14105,7 +14102,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14187,7 +14183,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14392,7 +14387,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14544,7 +14538,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14558,7 +14551,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15170,7 +15162,8 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.76",
+20 -19
View File
@@ -7,25 +7,26 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.5.0",
"@embedpdf/engines": "^1.5.0",
"@embedpdf/plugin-annotation": "^1.5.0",
"@embedpdf/plugin-bookmark": "^1.5.0",
"@embedpdf/plugin-export": "^1.5.0",
"@embedpdf/plugin-history": "^1.5.0",
"@embedpdf/plugin-interaction-manager": "^1.5.0",
"@embedpdf/plugin-loader": "^1.5.0",
"@embedpdf/plugin-pan": "^1.5.0",
"@embedpdf/plugin-print": "^1.5.0",
"@embedpdf/plugin-render": "^1.5.0",
"@embedpdf/plugin-rotate": "^1.5.0",
"@embedpdf/plugin-scroll": "^1.5.0",
"@embedpdf/plugin-search": "^1.5.0",
"@embedpdf/plugin-selection": "^1.5.0",
"@embedpdf/plugin-spread": "^1.5.0",
"@embedpdf/plugin-thumbnail": "^1.5.0",
"@embedpdf/plugin-tiling": "^1.5.0",
"@embedpdf/plugin-viewport": "^1.5.0",
"@embedpdf/plugin-zoom": "^1.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -3899,6 +3899,7 @@ 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"
+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 OnboardingTour from "@app/components/onboarding/OnboardingTour";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
import "@app/styles/tailwind.css";
@@ -19,7 +19,7 @@ export default function App() {
<AppProviders>
<AppLayout>
<HomePage />
<OnboardingTour />
<Onboarding />
</AppLayout>
</AppProviders>
</Suspense>
@@ -12,7 +12,6 @@ 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";
@@ -77,7 +76,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
@@ -113,7 +111,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
@@ -1,33 +0,0 @@
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,
};
}
@@ -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 RenderButtonsProps {
interface SlideButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
@@ -16,7 +16,7 @@ interface RenderButtonsProps {
onAction: (action: ButtonAction) => void;
}
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
@@ -105,5 +105,4 @@ export function renderButtons({ slideDefinition, licenseNotice, flowState, onAct
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
}
@@ -1,21 +0,0 @@
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,
};
@@ -1,381 +0,0 @@
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,
};
}
@@ -0,0 +1,319 @@
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,33 +1,44 @@
/**
* 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 LocalIcon from '@app/components/shared/LocalIcon';
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 AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
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 { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
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';
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
const flow = useInitialOnboardingState(props);
interface OnboardingModalSlideProps {
slideDefinition: SlideDefinition;
slideContent: SlideConfig;
runtimeState: OnboardingRuntimeState;
modalSlideCount: number;
currentModalSlideIndex: number;
onSkip: () => void;
onAction: (action: ButtonAction) => void;
}
if (!flow) {
return null;
}
const {
state,
totalSteps,
currentSlide,
slideDefinition,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
} = flow;
export default function OnboardingModalSlide({
slideDefinition,
slideContent,
runtimeState,
modalSlideCount,
currentModalSlideIndex,
onSkip,
onAction,
}: OnboardingModalSlideProps) {
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
@@ -48,6 +59,9 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
{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" />
@@ -58,8 +72,8 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
return (
<Modal
opened={props.opened}
onClose={closeAndMarkSeen}
opened={true}
onClose={onSkip}
closeOnClickOutside={false}
centered
size="lg"
@@ -67,48 +81,48 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
}}
>
<Stack gap={0} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
gradientStops={slideContent.background.gradientStops}
circles={slideContent.background.circles}
isActive
slideKey={currentSlide.key}
slideKey={slideContent.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
</div>
<div className={styles.modalBody}>
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
{slideContent.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{slideContent.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
licenseNotice,
flowState,
onAction: handleButtonAction,
})}
<SlideButtons
slideDefinition={slideDefinition}
licenseNotice={runtimeState.licenseNotice}
flowState={{ selectedRole: runtimeState.selectedRole }}
onAction={onAction}
/>
</div>
</Stack>
</div>
@@ -1,231 +1,151 @@
import React, { useEffect, useMemo } from "react";
import { TourProvider, type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
/**
* 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 { CloseButton, ActionIcon } from '@mantine/core';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
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";
import CheckIcon from '@mui/icons-material/Check';
import type { TFunction } from 'i18next';
import i18n from '@app/i18n';
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();
/**
* 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);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
previousIsOpenRef.current = forceOpen;
useEffect(() => {
if (!flow.isTourOpen) {
removeAllGlows();
if (wasClosedNowOpen) {
setCurrentStep(0);
}
return () => removeAllGlows();
}, [flow.isTourOpen]);
setIsOpen(forceOpen);
}, [forceOpen, setIsOpen, setCurrentStep]);
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,
],
);
return null;
}
const adminStepsConfig = useMemo(
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
},
}),
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
);
interface AdvanceArgs {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}
const steps = useMemo<StepType[]>(() => {
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
interface CloseArgs {
setIsOpen: (value: boolean) => 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));
}
};
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools';
isRTL: boolean;
t: TFunction;
isOpen: boolean;
onAdvance: (args: AdvanceArgs) => void;
onClose: (args: CloseArgs) => void;
}
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
flow.handleTourCompletion();
};
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
if (!isOpen) return null;
return (
<>
<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} />
</>
<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>
);
}
export type { AdvanceArgs, CloseArgs };
@@ -1,120 +0,0 @@
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>
);
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,82 +0,0 @@
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,8 +1,19 @@
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;
@@ -1,304 +0,0 @@
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,16 +3,18 @@ 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';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type ButtonAction =
| 'next'
@@ -46,6 +48,10 @@ 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 {
@@ -71,6 +77,17 @@ 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(),
@@ -197,11 +214,3 @@ 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[],
};
@@ -0,0 +1,127 @@
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);
}
@@ -0,0 +1,97 @@
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
}
}
@@ -0,0 +1,353 @@
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 };
}
@@ -0,0 +1,184 @@
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,
},
};
}
@@ -1,33 +0,0 @@
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,
}
@@ -0,0 +1,79 @@
/**
* 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,
};
}
@@ -0,0 +1,74 @@
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,6 +1,26 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { TourStep } from '@app/components/onboarding/tourSteps';
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,
}
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_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { Z_INDEX_CONFIG_MODAL, 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_OVER_FULLSCREEN_SURFACE}
zIndex={Z_INDEX_CONFIG_MODAL}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
@@ -130,11 +130,6 @@ 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>
@@ -15,8 +15,8 @@ import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events';
import {
isNavButtonActive,
@@ -34,7 +34,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const { startTour } = useOnboarding();
const licenseAlert = useLicenseAlert();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
@@ -269,7 +268,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
key={buttonConfig.id}
data-tour="help-button"
onClick={() => startTour('tools')}
onClick={() => requestStartTour('tools')}
>
{renderNavButton(buttonConfig, index)}
</div>
@@ -286,7 +285,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Menu.Dropdown>
<Menu.Item
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => startTour('tools')}
onClick={() => requestStartTour('tools')}
>
<div>
<div style={{ fontWeight: 500 }}>
@@ -299,7 +298,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => startTour('admin')}
onClick={() => requestStartTour('admin')}
>
<div>
<div style={{ fontWeight: 500 }}>
@@ -3,65 +3,39 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import '@app/components/tools/ToolPanelModePrompt.css';
import { type ToolPanelMode } from '@app/constants/toolPanel';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface ToolPanelModePromptProps {
onComplete?: () => void;
/** If true, the modal will be forced open (used by orchestrator) */
forceOpen?: boolean;
}
const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
/**
* ToolPanelModePrompt - Lets users choose between sidebar and fullscreen tool modes
*
* The orchestrator controls this via forceOpen prop. When shown standalone (legacy),
* it uses internal state based on preferences.
*/
const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps = {}) => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const { preferences, updatePreference } = usePreferences();
const {
startTour,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
pendingTourRequest,
} = useOnboarding();
const [opened, setOpened] = useState(false);
const { config } = useAppConfig();
const isAdmin = !!config?.isAdmin;
const [internalOpened, setInternalOpened] = useState(false);
// Only show after the new 3-slide onboarding has been completed
// Only show after the intro onboarding has been completed (legacy standalone mode)
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
useEffect(() => {
if (shouldShowPrompt) {
setOpened(true);
if (shouldShowPrompt && forceOpen === undefined) {
setInternalOpened(true);
}
}, [shouldShowPrompt]);
}, [shouldShowPrompt, forceOpen]);
const resolveRequestedTourType = (): 'admin' | 'tools' => {
if (pendingTourRequest?.type) {
return pendingTourRequest.type;
}
if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) {
return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools';
}
return isAdmin ? 'admin' : 'tools';
};
const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => {
if (!startAfterToolModeSelection) {
return;
}
setStartAfterToolModeSelection(false);
const targetType = resolveRequestedTourType();
startTour(targetType, {
skipToolPromptRequirement: true,
source: 'tool-panel-mode-prompt',
metadata: {
...pendingTourRequest?.metadata,
resumedFromToolPrompt: true,
...(context?.selection ? { selection: context.selection } : {}),
...(context?.dismissed ? { dismissed: true } : {}),
},
});
};
// If forceOpen is provided, use it; otherwise use internal state
const opened = forceOpen ?? internalOpened;
const setOpened = forceOpen !== undefined ? () => {} : setInternalOpened;
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
@@ -69,8 +43,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
setOpened(false);
resumeDeferredTour({ selection: mode });
onComplete?.();
};
@@ -83,7 +55,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
updatePreference('hasSelectedToolPanelMode', true);
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
resumeDeferredTour({ dismissed: true });
onComplete?.();
};
@@ -43,6 +43,7 @@ const EmbedPdfViewerContent = ({
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
searchInterfaceActions,
zoomActions,
panActions: _panActions,
rotationActions: _rotationActions,
@@ -184,7 +185,7 @@ const EmbedPdfViewerContent = ({
onZoomOut: zoomActions.zoomOut,
});
// Handle keyboard zoom shortcuts
// Handle keyboard shortcuts (zoom and search)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return;
@@ -199,6 +200,10 @@ const EmbedPdfViewerContent = ({
// Ctrl+- for zoom out
event.preventDefault();
zoomActions.zoomOut();
} else if (event.key === 'f' || event.key === 'F') {
// Ctrl+F for search
event.preventDefault();
searchInterfaceActions.open();
}
}
};
@@ -207,7 +212,7 @@ const EmbedPdfViewerContent = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isViewerHovered]);
}, [isViewerHovered, zoomActions, searchInterfaceActions]);
// Register checker for unsaved changes (annotations only for now)
useEffect(() => {
@@ -20,6 +20,7 @@ import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
// Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
@@ -41,6 +42,7 @@ import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
import { isPdfFile } from '@app/utils/fileUtils';
import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
@@ -95,7 +97,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
strategy: ScrollStrategy.Vertical,
initialPage: 0,
}),
createPluginRegistration(RenderPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: true,
withAnnotations: true,
}),
// Register interaction manager (required for zoom and selection features)
createPluginRegistration(InteractionManagerPluginPackage),
@@ -156,6 +161,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
createPluginRegistration(ExportPluginPackage, {
defaultFileName: 'document.pdf',
}),
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl]);
@@ -301,6 +309,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<PrintAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{
@@ -85,7 +85,10 @@ export function LocalEmbedPDFWithAnnotations({
strategy: ScrollStrategy.Vertical,
initialPage: 0,
}),
createPluginRegistration(RenderPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: true,
withAnnotations: true,
}),
// Register interaction manager (required for annotations)
createPluginRegistration(InteractionManagerPluginPackage),
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { usePrintCapability } from '@embedpdf/plugin-print/react';
import { useViewer } from '@app/contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and exposes print API to ViewerContext
*/
export function PrintAPIBridge() {
const { provides: print } = usePrintCapability();
const { registerBridge } = useViewer();
useEffect(() => {
if (print) {
// Register this bridge with ViewerContext
registerBridge('print', {
state: {},
api: {
print: () => print.print(),
}
});
}
}, [print, registerBridge]);
return null;
}
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
@@ -12,7 +12,9 @@ interface SearchInterfaceProps {
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchState = viewerContext?.getSearchState();
const searchResults = searchState?.results;
const searchActiveIndex = searchState?.activeIndex;
@@ -26,6 +28,38 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
} | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Auto-focus search input when visible
useEffect(() => {
if (visible) {
inputRef.current?.focus();
}
}, [visible]);
// Auto-search as user types (debounced)
useEffect(() => {
// Clear existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// If query is empty, clear search immediately
if (!searchQuery.trim()) {
handleClearSearch();
return;
}
// Debounce search by 300ms
searchTimeoutRef.current = setTimeout(() => {
handleSearch(searchQuery);
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery]);
// Monitor search state changes
useEffect(() => {
if (!visible) return;
@@ -123,7 +157,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
}
};
const _handleClose = () => {
const handleInputBlur = () => {
// Close popover on blur if no text is entered
if (!searchQuery.trim()) {
onClose();
}
};
const handleCloseClick = () => {
handleClearSearch();
onClose();
};
@@ -135,37 +176,44 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
padding: '0px'
}}
>
{/* Header */}
<Group mb="md">
{/* Header with close button */}
<Group mb="md" justify="space-between">
<Text size="sm" fw={600}>
{t('search.title', 'Search PDF')}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
{/* Search input */}
<Group mb="md">
<TextInput
ref={inputRef}
placeholder={t('search.placeholder', 'Enter search term...')}
value={searchQuery}
onChange={(e) => {
const newValue = e.currentTarget.value;
setSearchQuery(newValue);
// If user clears the input, clear the search highlights
if (!newValue.trim()) {
handleClearSearch();
}
}}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
style={{ flex: 1 }}
rightSection={
<ActionIcon
variant="subtle"
onClick={() => handleSearch(searchQuery)}
disabled={!searchQuery.trim() || isSearching}
loading={isSearching}
>
<LocalIcon icon="search" width="1rem" height="1rem" />
</ActionIcon>
searchQuery.trim() && (
<ActionIcon
variant="subtle"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon>
)
}
/>
</Group>
@@ -24,6 +24,7 @@ export function useViewerRightRailButtons() {
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const printLabel = t('rightRail.print', 'Print PDF');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
@@ -35,7 +36,14 @@ export function useViewerRightRailButtons() {
order: 10,
render: ({ disabled }) => (
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
<Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
opened={viewer.isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@@ -44,6 +52,7 @@ export function useViewerRightRailButtons() {
className="right-rail-icon"
disabled={disabled}
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -51,7 +60,7 @@ export function useViewerRightRailButtons() {
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface visible={true} onClose={() => {}} />
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
</div>
</Popover.Dropdown>
</Popover>
@@ -127,6 +136,17 @@ export function useViewerRightRailButtons() {
viewer.toggleBookmarkSidebar();
}
},
{
id: 'viewer-print',
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
tooltip: printLabel,
ariaLabel: printLabel,
section: 'top' as const,
order: 56,
onClick: () => {
viewer.printActions.print();
}
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@@ -136,7 +156,7 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, tooltipPosition]);
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
useRightRailButtons(viewerButtons);
}
+20 -3
View File
@@ -1,7 +1,24 @@
// Base path from Vite config - build-time constant, normalized (no trailing slash)
// When no subpath, use empty string instead of '.' to avoid relative path issues
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
// Base path - read from <base> tag at runtime to support dynamic subpaths
// Falls back to Vite's BASE_URL for build-time subpaths
// Normalized: no trailing slash, empty string instead of '.' or '/'
function getBasePath(): string {
// Try to read from <base> tag first (runtime subpath support)
if (typeof document !== 'undefined') {
const baseElement = document.querySelector('base');
if (baseElement) {
const href = baseElement.getAttribute('href');
if (href && href !== '%BASE_URL%') {
return href.replace(/\/$/, '').replace(/^\.$/, '').replace(/^\/$/, '');
}
}
}
// Fall back to Vite's BASE_URL (build-time subpath)
return (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '').replace(/^\/$/, '');
}
export const BASE_PATH = getBasePath();
// EmbedPDF needs time to remove annotations internally before a recreation runs.
// Without the buffer we occasionally end up with duplicate annotations or stale image data.
+28 -2
View File
@@ -1,10 +1,10 @@
import type { LicenseNotice } from '@app/types/types';
export const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
export const ONBOARDING_SESSION_EVENT = 'stirling:onboarding-session-started';
export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested';
export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
export const START_TOUR_EVENT = 'stirling:start-tour';
export const TOUR_STATE_EVENT = 'stirling:tour-state';
export interface ServerLicenseRequestPayload {
licenseNotice?: Partial<LicenseNotice>;
@@ -25,3 +25,29 @@ export interface UpgradeBannerAlertPayload {
freeTierLimit?: number;
}
export type TourType = 'admin' | 'tools';
export interface StartTourPayload {
tourType: TourType;
}
export interface TourStatePayload {
isOpen: boolean;
}
/** Helper to dispatch the start tour event */
export function requestStartTour(tourType: TourType): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent<StartTourPayload>(START_TOUR_EVENT, { detail: { tourType } })
);
}
/** Helper to dispatch tour state changes (for hiding cookie consent during tour) */
export function dispatchTourState(isOpen: boolean): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent<TourStatePayload>(TOUR_STATE_EVENT, { detail: { isOpen } })
);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Route constants used across the application
*/
/**
* Routes where onboarding, cookie consent, and upgrade banners should not appear.
* These are authentication-related pages where users are not yet logged in or
* the main app chrome is not displayed.
*/
export const AUTH_ROUTES = [
'/login',
'/signup',
'/auth',
'/invite',
'/forgot-password',
'/reset-password',
];
/**
* Check if a pathname matches any auth route
*/
export function isAuthRoute(pathname: string): boolean {
return AUTH_ROUTES.some((route) => pathname.startsWith(route));
}
@@ -1,159 +0,0 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
export type TourType = 'tools' | 'admin';
export interface StartTourOptions {
source?: string;
skipToolPromptRequirement?: boolean;
metadata?: Record<string, unknown>;
}
interface PendingTourRequest {
type: TourType;
source?: string;
metadata?: Record<string, unknown>;
requestedAt: number;
}
interface OnboardingContextValue {
isOpen: boolean;
currentStep: number;
tourType: TourType;
setCurrentStep: (step: number) => void;
startTour: (type?: TourType, options?: StartTourOptions) => void;
closeTour: () => void;
completeTour: () => void;
resetTour: (type?: TourType) => void;
startAfterToolModeSelection: boolean;
setStartAfterToolModeSelection: (value: boolean) => void;
pendingTourRequest: PendingTourRequest | null;
clearPendingTourRequest: () => void;
}
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { preferences, updatePreference } = usePreferences();
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [tourType, setTourType] = useState<TourType>('tools');
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
const [pendingTourRequest, setPendingTourRequest] = useState<PendingTourRequest | null>(null);
const openTour = useCallback((type: TourType = 'tools') => {
setTourType(type);
setCurrentStep(0);
setIsOpen(true);
}, []);
const startTour = useCallback(
(type: TourType = 'tools', options?: StartTourOptions) => {
const requestedType = type ?? 'tools';
const source = options?.source ?? 'unspecified';
const metadata = options?.metadata;
const skipToolPromptRequirement = options?.skipToolPromptRequirement ?? false;
const toolPromptSeen = preferences.toolPanelModePromptSeen;
const hasSelectedToolPanelMode = preferences.hasSelectedToolPanelMode;
const hasToolPreference = toolPromptSeen || hasSelectedToolPanelMode;
const shouldDefer = !skipToolPromptRequirement && !hasToolPreference;
console.log('[onboarding] startTour invoked', {
requestedType,
source,
toolPromptSeen,
hasSelectedToolPanelMode,
shouldDefer,
hasPendingTourRequest: !!pendingTourRequest,
metadata,
});
if (shouldDefer) {
setPendingTourRequest({
type: requestedType,
source,
metadata,
requestedAt: Date.now(),
});
setStartAfterToolModeSelection(true);
console.log('[onboarding] deferring tour launch until tool panel mode selection completes', {
requestedType,
source,
});
return;
}
if (pendingTourRequest) {
console.log('[onboarding] clearing previous pending tour request before starting new tour', {
previousRequest: pendingTourRequest,
newType: requestedType,
source,
});
}
setPendingTourRequest(null);
setStartAfterToolModeSelection(false);
console.log('[onboarding] starting tour', {
requestedType,
source,
});
openTour(requestedType);
},
[openTour, pendingTourRequest, preferences.toolPanelModePromptSeen, preferences.hasSelectedToolPanelMode],
);
const closeTour = useCallback(() => {
setIsOpen(false);
}, []);
const completeTour = useCallback(() => {
setIsOpen(false);
updatePreference('hasCompletedOnboarding', true);
}, [updatePreference]);
const resetTour = useCallback((type: TourType = 'tools') => {
updatePreference('hasCompletedOnboarding', false);
setTourType(type);
setCurrentStep(0);
setIsOpen(true);
}, [updatePreference]);
const clearPendingTourRequest = useCallback(() => {
if (pendingTourRequest) {
console.log('[onboarding] clearing pending tour request manually', {
pendingTourRequest,
});
}
setPendingTourRequest(null);
setStartAfterToolModeSelection(false);
}, [pendingTourRequest]);
return (
<OnboardingContext.Provider
value={{
isOpen,
currentStep,
tourType,
setCurrentStep,
startTour,
closeTour,
completeTour,
resetTour,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
pendingTourRequest,
clearPendingTourRequest,
}}
>
{children}
</OnboardingContext.Provider>
);
};
export const useOnboarding = (): OnboardingContextValue => {
const context = useContext(OnboardingContext);
if (!context) {
throw new Error('useOnboarding must be used within an OnboardingProvider');
}
return context;
};
@@ -110,7 +110,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
const loadSampleFile = useCallback(async () => {
try {
const response = await fetch('/samples/Sample.pdf');
const response = await fetch('samples/Sample.pdf');
const blob = await response.blob();
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
@@ -18,6 +18,7 @@ import {
SearchActions,
ExportActions,
BookmarkActions,
PrintActions,
} from '@app/contexts/viewer/viewerActions';
import {
BridgeRef,
@@ -79,6 +80,14 @@ interface ViewerContextType {
isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
searchInterfaceActions: {
open: () => void;
close: () => void;
toggle: () => void;
};
// Annotation visibility toggle
isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void;
@@ -125,6 +134,7 @@ interface ViewerContextType {
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
printActions: PrintActions;
// Bridge registration - internal use by bridges
registerBridge: <K extends BridgeKey>(
@@ -143,6 +153,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
@@ -205,6 +216,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
toggle: () => setSearchInterfaceVisible(prev => !prev),
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
@@ -277,6 +294,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
searchActions,
exportActions,
bookmarkActions,
printActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
@@ -291,6 +309,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Search interface
isSearchInterfaceVisible,
searchInterfaceActions,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,
@@ -333,6 +355,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
searchActions,
exportActions,
bookmarkActions,
printActions,
// Bridge registration
registerBridge,
@@ -65,6 +65,10 @@ export interface BookmarkActions {
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
}
export interface PrintActions {
print: () => void;
}
export interface ViewerActionsBundle {
scrollActions: ScrollActions;
zoomActions: ZoomActions;
@@ -75,6 +79,7 @@ export interface ViewerActionsBundle {
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
printActions: PrintActions;
}
interface ViewerActionDependencies {
@@ -332,5 +337,13 @@ export function createViewerActions({
api?.setLocalBookmarks?.(bookmarks ?? null, error);
},
},
printActions: {
print: () => {
const api = registry.current.print?.api;
if (api?.print) {
api.print();
}
},
},
};
}
@@ -49,6 +49,10 @@ export interface SearchAPIWrapper {
goToResult: (index: number) => void;
}
export interface PrintAPIWrapper {
print: () => void;
}
export interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => {
toPromise: () => Promise<Blob>;
@@ -132,6 +136,7 @@ export interface BridgeStateMap {
thumbnail: unknown;
export: ExportState;
bookmark: BookmarkState;
print: unknown;
}
export interface BridgeApiMap {
@@ -145,6 +150,7 @@ export interface BridgeApiMap {
thumbnail: ThumbnailAPIWrapper;
export: ExportAPIWrapper;
bookmark: BookmarkAPIWrapper;
print: PrintAPIWrapper;
}
export type BridgeKey = keyof BridgeStateMap;
@@ -164,6 +170,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
thumbnail: null,
export: null,
bookmark: null,
print: null,
});
export function registerBridge<K extends BridgeKey>(
+23 -58
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '@app/constants/app';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
declare global {
interface Window {
@@ -28,16 +28,11 @@ export const useCookieConsent = ({
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { isOpen: tourIsOpen } = useOnboarding();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (!analyticsEnabled) {
console.log('Cookie consent not enabled - analyticsEnabled is false');
return;
}
if (!analyticsEnabled) return;
// Load the cookie consent CSS files first (always needed)
const mainCSS = document.createElement('link');
mainCSS.rel = 'stylesheet';
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
@@ -52,26 +47,19 @@ export const useCookieConsent = ({
document.head.appendChild(customCSS);
}
// Prevent double initialization
if (window.CookieConsent) {
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
}
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent?.show();
}, 100);
return;
}
// Load the cookie consent library
const script = document.createElement('script');
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
script.onload = () => {
// Small delay to ensure DOM is ready
setTimeout(() => {
// Detect current theme and set appropriate mode
const detectTheme = () => {
// If forceLightMode is enabled, always use light mode
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
@@ -82,7 +70,6 @@ export const useCookieConsent = ({
const hasDarkClass = document.documentElement.classList.contains('dark');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Priority: Mantine attribute > CSS classes > system preference
let isDarkMode = false;
if (mantineScheme) {
@@ -95,24 +82,17 @@ export const useCookieConsent = ({
isDarkMode = systemPrefersDark;
}
// Always explicitly set or remove the class
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
return isDarkMode;
};
// Initial theme detection with slight delay to ensure DOM is ready
setTimeout(() => {
detectTheme();
}, 50);
setTimeout(() => detectTheme(), 50);
// Check if CookieConsent is available
if (!window.CookieConsent) {
console.error('CookieConsent is not available on window object');
return;
}
// Listen for theme changes (but not if forceLightMode is enabled)
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
@@ -131,8 +111,6 @@ export const useCookieConsent = ({
});
}
// Initialize cookie consent with full configuration
try {
window.CookieConsent.run({
autoShow: true,
@@ -160,15 +138,11 @@ export const useCookieConsent = ({
...(config?.enablePosthog !== false && {
posthog: {
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
onAccept: () => console.log('PostHog service accepted'),
onReject: () => console.log('PostHog service rejected')
}
}),
...(config?.enableScarf !== false && {
scarf: {
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
onAccept: () => console.log('Scarf service accepted'),
onReject: () => console.log('Scarf service rejected')
}
})
}
@@ -224,16 +198,11 @@ export const useCookieConsent = ({
}
});
// Force show after initialization
setTimeout(() => {
window.CookieConsent?.show();
}, 200);
} catch (error) {
console.error('Error initializing CookieConsent:', error);
}
setIsInitialized(true);
}, 100); // Small delay to ensure DOM is ready
setIsInitialized(true);
}, 100);
};
script.onerror = () => {
@@ -243,7 +212,6 @@ export const useCookieConsent = ({
document.head.appendChild(script);
return () => {
// Cleanup script and CSS when component unmounts
if (document.head.contains(script)) {
document.head.removeChild(script);
}
@@ -254,9 +222,8 @@ export const useCookieConsent = ({
document.head.removeChild(customCSS);
}
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t, forceLightMode]);
// Update theme when forceLightMode changes
useEffect(() => {
if (!isInitialized) return;
@@ -286,10 +253,8 @@ export const useCookieConsent = ({
return isDarkMode;
};
// Update theme immediately
detectTheme();
// Set up or remove theme observer based on forceLightMode
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
@@ -315,23 +280,23 @@ export const useCookieConsent = ({
};
}, [forceLightMode, isInitialized]);
// Hide cookie banner when tour is active
useEffect(() => {
if (!isInitialized || !window.CookieConsent) {
return;
}
if (!isInitialized || !window.CookieConsent) return;
if (tourIsOpen) {
window.CookieConsent.hide();
} else {
// Only show if user hasn't made a choice yet
const consentCookie = window.CookieConsent.getCookie?.();
const hasConsented = consentCookie && Object.keys(consentCookie).length > 0;
if (!hasConsented) {
window.CookieConsent.show();
const handleTourState = (event: Event) => {
const { detail } = event as CustomEvent<TourStatePayload>;
if (detail?.isOpen) {
window.CookieConsent?.hide();
} else {
const consentCookie = window.CookieConsent?.getCookie?.();
const hasConsented = consentCookie && Object.keys(consentCookie).length > 0;
if (!hasConsented) window.CookieConsent?.show();
}
}
}, [tourIsOpen, isInitialized]);
};
window.addEventListener(TOUR_STATE_EVENT, handleTourState);
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourState);
}, [isInitialized]);
const showCookieConsent = useCallback(() => {
if (isInitialized && window.CookieConsent) {
-13
View File
@@ -22,7 +22,6 @@ import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
import "@app/pages/HomePage.css";
@@ -53,20 +52,12 @@ export default function HomePage() {
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const { activeFiles } = useFileContext();
const { actions } = useNavigationActions();
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
setShowAnalyticsModal(true);
}
}, [config]);
// Auto-switch to viewer when going from 0 to 1 file
useEffect(() => {
const prevCount = prevFileCountRef.current;
@@ -181,10 +172,6 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<AdminAnalyticsChoiceModal
opened={showAnalyticsModal}
onClose={() => setShowAnalyticsModal(false)}
/>
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
@@ -9,11 +9,26 @@ export interface AccountData {
saml2Login: boolean;
}
export interface LoginPageData {
showDefaultCredentials: boolean;
firstTimeSetup: boolean;
enableLogin: boolean;
}
/**
* Account Service
* Provides functions to interact with account-related backend APIs
*/
export const accountService = {
/**
* Get login page data (includes showDefaultCredentials flag)
* This is a public endpoint - doesn't require authentication
*/
async getLoginPageData(): Promise<LoginPageData> {
const response = await apiClient.get<LoginPageData>('/api/v1/proprietary/ui-data/login');
return response.data;
},
/**
* Get current user account data
*/
+2
View File
@@ -4,6 +4,8 @@
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301;
// Config/Settings modal - should appear above analytics modal when navigating from onboarding
export const Z_INDEX_CONFIG_MODAL = 1400;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
@@ -1,390 +0,0 @@
/**
* Desktop override of useInitialOnboardingState.
*
* Key difference: Handles the simplified onboarding flow where desktop-install
* and security-check slides are removed. When welcome is the last slide,
* clicking Next will launch the tools tour instead of doing nothing.
*/
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],
);
// Desktop: No security-check slide, so no need to filter it out
const flowSlideIds = resolvedFlow.ids;
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 isOnLastSlide = state.step >= maxIndex;
// Desktop: For login-user and no-login flows, when on the last slide (welcome),
// clicking Next should launch the tools tour
const shouldAutoLaunchToolsTour =
(flowType === 'login-user' || flowType === 'no-login') && isOnLastSlide;
switch (action) {
case 'next':
if (shouldAutoLaunchToolsTour) {
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 (shouldAutoLaunchToolsTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
}
case 'complete-close':
updatePreference('hasCompletedOnboarding', true);
closeAndMarkSeen();
return;
case 'security-next':
// Desktop: security-check slide is removed, but keep this for completeness
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,
currentSlideId,
effectiveIsAdmin,
flowType,
goNext,
goPrev,
launchTour,
maxIndex,
navigate,
requestServerLicenseIfNeeded,
onRequestServerLicense,
os.url,
state.selectedRole,
state.selfReportedAdmin,
state.step,
updatePreference,
],
);
const flowState: FlowState = { selectedRole: state.selectedRole };
return {
state,
totalSteps,
slideDefinition,
currentSlide,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
};
}
@@ -1,178 +0,0 @@
/**
* Desktop override of onboarding flow config.
*
* This version removes the desktop-install and security-check slides
* since they're not relevant when running as a desktop app.
*
* The SlideId type still includes all values for type compatibility,
* but the actual FLOW_SEQUENCES don't use these slides.
*/
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
// Keep the full type for compatibility, but these slides won't be used
export type SlideId =
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'complete-close'
| 'download-selected'
| 'security-next'
| 'launch-admin'
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license';
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
}
export interface OSOption {
label: string;
url: string;
value: string;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
}
export interface HeroDefinition {
type: HeroType;
}
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export interface SlideDefinition {
id: SlideId;
createSlide: (params: SlideFactoryParams) => SlideConfig;
hero: HeroDefinition;
buttons: ButtonDefinition[];
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'welcome': {
id: 'welcome',
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
// Stub definitions for desktop-install and security-check - not used on desktop
// but kept for type compatibility with core code
'desktop-install': {
id: 'desktop-install',
createSlide: () => WelcomeSlide(), // Placeholder - never used
hero: { type: 'dual-icon' },
buttons: [],
},
'security-check': {
id: 'security-check',
createSlide: () => WelcomeSlide(), // Placeholder - never used
hero: { type: 'shield' },
buttons: [],
},
'admin-overview': {
id: 'admin-overview',
createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: 'diamond' },
buttons: [
{
key: 'admin-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'admin-show',
type: 'button',
label: 'onboarding.buttons.showMeAround',
variant: 'primary',
group: 'right',
action: 'launch-admin',
},
{
key: 'admin-skip',
type: 'button',
label: 'onboarding.buttons.skipTheTour',
variant: 'secondary',
group: 'left',
action: 'skip-to-license',
},
],
},
'server-license': {
id: 'server-license',
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'license-close',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
},
{
key: 'license-see-plans',
type: 'button',
label: 'onboarding.serverLicense.seePlans',
variant: 'primary',
group: 'right',
action: 'see-plans',
},
],
},
};
/**
* Desktop flow sequences - simplified without desktop-install and security-check slides
* since users are already on desktop and security check is not needed.
*/
export const FLOW_SEQUENCES = {
loginAdmin: ['welcome', 'admin-overview'] as SlideId[],
loginUser: ['welcome'] as SlideId[],
noLoginBase: ['welcome'] as SlideId[],
noLoginAdmin: ['admin-overview'] as SlideId[],
};
@@ -0,0 +1,38 @@
/**
* Desktop Override: Onboarding Configuration
*
* This version modifies the onboarding config for the desktop app:
* - Sets isDesktopApp to true in the default runtime state
* - This causes desktop-install step to be skipped
*
* All other step definitions and logic remain the same.
*/
// Re-export everything from core
export {
ONBOARDING_STEPS,
getStepById,
getStepIndex,
} from '@core/components/onboarding/orchestrator/onboardingConfig';
export type {
OnboardingStepId,
OnboardingStepType,
OnboardingStep,
OnboardingRuntimeState,
OnboardingConditionContext,
} from '@core/components/onboarding/orchestrator/onboardingConfig';
// Import and override the default runtime state
import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from '@core/components/onboarding/orchestrator/onboardingConfig';
import type { OnboardingRuntimeState } from '@core/components/onboarding/orchestrator/onboardingConfig';
/**
* Desktop default runtime state
* Sets isDesktopApp to true so desktop-install step is skipped
*/
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
...CORE_DEFAULT_RUNTIME_STATE,
isDesktopApp: true,
};
@@ -0,0 +1,22 @@
/**
* Desktop Override: Onboarding Orchestrator Hook
*
* Simply wraps the core hook with the desktop-specific default runtime state
* which has isDesktopApp set to true.
*/
import {
useOnboardingOrchestrator as useCoreOnboardingOrchestrator,
type UseOnboardingOrchestratorResult,
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { DEFAULT_RUNTIME_STATE } from '@desktop/components/onboarding/orchestrator/onboardingConfig';
export type {
OnboardingOrchestratorState,
OnboardingOrchestratorActions,
UseOnboardingOrchestratorResult,
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult {
return useCoreOnboardingOrchestrator({ defaultRuntimeState: DEFAULT_RUNTIME_STATE });
}
+2 -2
View File
@@ -8,7 +8,7 @@ import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
import "@app/styles/tailwind.css";
@@ -34,7 +34,7 @@ export default function App() {
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
<Onboarding />
</AppLayout>
</AppProviders>
</Suspense>
@@ -1,12 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@core/constants/routes';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import {
ONBOARDING_SESSION_BLOCK_KEY,
ONBOARDING_SESSION_EVENT,
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
UPGRADE_BANNER_TEST_EVENT,
@@ -15,13 +13,17 @@ import {
UPGRADE_BANNER_ALERT_EVENT,
} from '@core/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage';
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { isOpen: tourOpen } = useOnboarding();
const location = useLocation();
// Check if we're on an auth route (evaluated after hooks, used in render)
const onAuthRoute = isAuthRoute(location.pathname);
const { openCheckout } = useCheckout();
const {
totalUsers,
@@ -34,40 +36,18 @@ const UpgradeBanner: React.FC = () => {
overFreeTierLimit,
scenarioKey,
} = useServerExperience();
const [sessionBlocked, setSessionBlocked] = useState(true);
const [friendlyVisible, setFriendlyVisible] = useState(false);
const onboardingComplete = hasSeenStep('welcome');
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === 'undefined') return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
if (!lastShownRaw) return false;
const lastShown = parseInt(lastShownRaw, 10);
if (!Number.isFinite(lastShown)) return false;
return Date.now() - lastShown >= WEEK_IN_MS;
});
const isDev = import.meta.env.DEV;
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
// Track onboarding session flag so we don't show banner if onboarding ran this load
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const evaluateBlock = () => {
const blocked = window.sessionStorage.getItem(ONBOARDING_SESSION_BLOCK_KEY) === 'true';
setSessionBlocked(blocked);
};
evaluateBlock();
const timer = window.setTimeout(() => {
evaluateBlock();
}, 1000);
const handleOnboardingEvent = () => {
evaluateBlock();
};
window.addEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
return () => {
clearTimeout(timer);
window.removeEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
};
}, []);
useEffect(() => {
if (!isDev || typeof window === 'undefined') {
return;
@@ -157,28 +137,16 @@ const UpgradeBanner: React.FC = () => {
shouldShowFriendlyBase &&
!licenseLoading &&
effectiveTotalUsersLoaded &&
!tourOpen &&
!sessionBlocked,
onboardingComplete,
);
// Urgent banner should always show when over-limit
const shouldEvaluateUrgent = scenario
? Boolean(scenario && !scenarioIsFriendly)
: Boolean(
shouldShowUrgentBase &&
!licenseLoading &&
!tourOpen &&
!sessionBlocked,
!licenseLoading,
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) {
window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY);
}
}, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]);
useEffect(() => {
if (scenario === 'friendly') {
return;
@@ -214,16 +182,6 @@ const UpgradeBanner: React.FC = () => {
}
: { active: false };
console.debug('[UpgradeBanner] Dispatching alert event', {
shouldEvaluateUrgent,
detail,
totalUsers: effectiveTotalUsers,
freeTierLimit,
effectiveIsAdmin,
effectiveHasPaidLicense,
userCountLoaded: effectiveTotalUsersLoaded,
});
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
);
@@ -246,12 +204,6 @@ const UpgradeBanner: React.FC = () => {
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
}, []);
useEffect(() => {
if (friendlyVisible) {
recordFriendlyLastShown();
}
}, [friendlyVisible, recordFriendlyLastShown]);
const handleUpgrade = () => {
recordFriendlyLastShown();
@@ -308,15 +260,8 @@ const UpgradeBanner: React.FC = () => {
const renderUrgentBanner = () => {
if (!shouldEvaluateUrgent) {
console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)');
return null;
}
console.debug('[UpgradeBanner] renderUrgentBanner → visible', {
totalUsers: effectiveTotalUsers,
freeTierLimit,
effectiveIsAdmin,
effectiveHasPaidLicense,
});
const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
@@ -351,7 +296,8 @@ const UpgradeBanner: React.FC = () => {
);
};
if (!friendlyVisible && !shouldEvaluateUrgent) {
// Don't show on auth routes or if neither banner type should show
if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) {
return null;
}
@@ -335,7 +335,7 @@ export default function AdminGeneralSection() {
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0' }}>
<img
src="/classic-logo/favicon.ico"
src="classic-logo/favicon.ico"
alt={t('admin.settings.general.logoStyle.classicAlt', 'Classic logo')}
style={{ width: '24px', height: '24px' }}
/>
@@ -348,7 +348,7 @@ export default function AdminGeneralSection() {
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0' }}>
<img
src="/modern-logo/StirlingPDFLogoNoTextLight.svg"
src="modern-logo/StirlingPDFLogoNoTextLight.svg"
alt={t('admin.settings.general.logoStyle.modernAlt', 'Modern logo')}
style={{ width: '24px', height: '24px' }}
/>
@@ -173,19 +173,8 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
}
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
const shouldUseEstimate = config.enableLogin === false;
if (!shouldUseAdminData && !shouldUseEstimate) {
setUserCountState((prev) => ({
...prev,
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
error: null,
}));
return;
}
// Use WAU estimate for no-login scenarios OR for login non-admin users
const shouldUseEstimate = config.enableLogin === false || !config.isAdmin;
setUserCountState((prev) => ({
...prev,
+5 -49
View File
@@ -1,11 +1,8 @@
import { useState, useEffect } from 'react'
import { useEffect } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage'
// Login component is used via routing, not directly imported
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
import { accountService } from '@app/services/accountService'
import { useBackendProbe } from '@app/hooks/useBackendProbe'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import LoginHeader from '@app/routes/login/LoginHeader'
@@ -19,15 +16,12 @@ import { useTranslation } from 'react-i18next'
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading, refreshSession } = useAuth();
const { session, loading: authLoading } = useAuth();
const { config, loading: configLoading, refetch } = useAppConfig();
const backendProbe = useBackendProbe();
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
const [isFirstLogin, setIsFirstLogin] = useState(false);
const [checkingFirstLogin, setCheckingFirstLogin] = useState(false);
const [username, setUsername] = useState('');
const loading = authLoading || configLoading || backendProbe.loading;
@@ -51,42 +45,12 @@ export default function Landing() {
return () => window.clearInterval(intervalId);
}, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]);
// Check if user needs to change password on first login
useEffect(() => {
const checkFirstLogin = async () => {
if (session && config?.enableLogin !== false) {
try {
setCheckingFirstLogin(true)
const accountData = await accountService.getAccountData()
setUsername(accountData.username)
setIsFirstLogin(accountData.changeCredsFlag)
} catch (err) {
console.error('Failed to check first login status:', err)
// If account endpoint fails (404), user probably doesn't have security enabled
setIsFirstLogin(false)
} finally {
setCheckingFirstLogin(false)
}
}
}
checkFirstLogin()
}, [session, config])
useEffect(() => {
if (backendProbe.status === 'up') {
void refetch();
}
}, [backendProbe.status, refetch]);
const handlePasswordChanged = async () => {
// After password change, backend logs out the user
// Refresh session to detect logout and redirect to login
setIsFirstLogin(false) // Close modal first
await refreshSession()
// The auth system will automatically redirect to login when session is null
}
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
@@ -95,7 +59,7 @@ export default function Landing() {
});
// Show loading while checking auth and config
if (loading || checkingFirstLogin) {
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
@@ -154,17 +118,9 @@ export default function Landing() {
}
// If we have a session, show the main app
// Note: First login password change is now handled by the onboarding flow
if (session) {
return (
<>
<FirstLoginModal
opened={isFirstLogin}
onPasswordChanged={handlePasswordChanged}
username={username}
/>
<HomePage />
</>
);
return <HomePage />;
}
// No session - redirect to login page
@@ -7,7 +7,6 @@ import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
import { OnboardingProvider } from '@app/contexts/OnboardingContext';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
@@ -67,7 +66,7 @@ vi.mock('react-router-dom', async () => {
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>
<PreferencesProvider>
<OnboardingProvider>{children}</OnboardingProvider>
{children}
</PreferencesProvider>
</MantineProvider>
);
@@ -129,8 +129,12 @@ const SIMULATION_SCENARIOS: SimulationScenario[] = [
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 3,
// Non-admin users use WAU estimate (not adminUsage)
wau: {
trackingSince: '2025-11-18T23:20:12.520884200Z',
daysOnline: 0,
totalUniqueBrowsers: 3,
weeklyActiveUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
@@ -151,8 +155,12 @@ const SIMULATION_SCENARIOS: SimulationScenario[] = [
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 12,
// Non-admin users use WAU estimate (not adminUsage)
wau: {
trackingSince: '2025-09-01T00:00:00Z',
daysOnline: 30,
totalUniqueBrowsers: 12,
weeklyActiveUsers: 9,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},